Repository: ndonfris/fish-lsp Branch: master Commit: 3a1b117fa5dd Files: 316 Total size: 2.8 MB Directory structure: gitextract_6mzjd793/ ├── .all-contributorsrc ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ └── feature_request.md │ └── workflows/ │ ├── check-npm-release.yml │ ├── ci.yml │ └── test-npm-package.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ ├── post-checkout │ ├── post-merge │ ├── pre-commit │ └── prepare-commit-msg ├── .nvmrc ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── eslint.config.ts ├── fish_files/ │ ├── exec.fish │ ├── expand_cartesian.fish │ ├── get-autoloaded-filepath.fish │ ├── get-command-options.fish │ ├── get-completion.fish │ ├── get-dependency.fish │ ├── get-docs.fish │ ├── get-documentation.fish │ ├── get-fish-autoloaded-paths.fish │ ├── get-type-verbose.fish │ └── get-type.fish ├── man/ │ └── fish-lsp.1 ├── package.json ├── renovate.json ├── scripts/ │ ├── build-assets.fish │ ├── build-completions.fish │ ├── build-time │ ├── dev-complete.fish │ ├── esbuild/ │ │ ├── cli.ts │ │ ├── colors.ts │ │ ├── configs.ts │ │ ├── file-watcher.ts │ │ ├── index.ts │ │ ├── pipeline.ts │ │ ├── plugins.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── fish/ │ │ ├── continue-or-exit.fish │ │ ├── pretty-print.fish │ │ └── utils.fish │ ├── fish-commands-scrapper.ts │ ├── publish-nightly.fish │ ├── relink-locally.fish │ ├── update-changelog.fish │ ├── update-codeblocks-in-docs.ts │ └── workspace-cli.ts ├── src/ │ ├── analyze.ts │ ├── cli.ts │ ├── code-actions/ │ │ ├── action-kinds.ts │ │ ├── alias-wrapper.ts │ │ ├── argparse-completions.ts │ │ ├── code-action-handler.ts │ │ ├── combiner.ts │ │ ├── disable-actions.ts │ │ ├── quick-fixes.ts │ │ ├── redirect-actions.ts │ │ └── refactors.ts │ ├── code-lens.ts │ ├── command.ts │ ├── config.ts │ ├── diagnostics/ │ │ ├── buffered-async-cache.ts │ │ ├── comments-handler.ts │ │ ├── diagnostic-ranges.ts │ │ ├── error-codes.ts │ │ ├── invalid-error-code.ts │ │ ├── missing-completions.ts │ │ ├── no-execute-diagnostic.ts │ │ ├── node-types.ts │ │ ├── types.ts │ │ └── validate.ts │ ├── document-highlight.ts │ ├── document.ts │ ├── documentation.ts │ ├── execute-handler.ts │ ├── formatting.ts │ ├── hover.ts │ ├── inlay-hints.ts │ ├── linked-editing.ts │ ├── logger.ts │ ├── main.ts │ ├── parser.ts │ ├── parsing/ │ │ ├── alias.ts │ │ ├── argparse.ts │ │ ├── barrel.ts │ │ ├── bind.ts │ │ ├── comments.ts │ │ ├── complete.ts │ │ ├── emit.ts │ │ ├── equality-utils.ts │ │ ├── export.ts │ │ ├── for.ts │ │ ├── function.ts │ │ ├── inline-variable.ts │ │ ├── nested-strings.ts │ │ ├── options.ts │ │ ├── read.ts │ │ ├── reference-comparator.ts │ │ ├── set.ts │ │ ├── source.ts │ │ ├── string.ts │ │ ├── symbol-converters.ts │ │ ├── symbol-detail.ts │ │ ├── symbol-kinds.ts │ │ ├── symbol-modifiers.ts │ │ ├── symbol.ts │ │ ├── unreachable.ts │ │ └── values.ts │ ├── references.ts │ ├── renames.ts │ ├── selection-range.ts │ ├── semantic-tokens.ts │ ├── server.ts │ ├── signature.ts │ ├── snippets/ │ │ ├── envVariables.json │ │ ├── fishlspEnvVariables.json │ │ ├── functions.json │ │ ├── helperCommands.json │ │ ├── localeVariables.json │ │ ├── pipesAndRedirects.json │ │ ├── specialFishVariables.json │ │ ├── statusNumbers.json │ │ └── syntaxHighlightingVariables.json │ ├── utils/ │ │ ├── builtins.ts │ │ ├── cli-dump-tree.ts │ │ ├── commander-cli-subcommands.ts │ │ ├── completion/ │ │ │ ├── comment-completions.ts │ │ │ ├── documentation.ts │ │ │ ├── inline-parser.ts │ │ │ ├── list.ts │ │ │ ├── pager.ts │ │ │ ├── shell.ts │ │ │ ├── startup-cache.ts │ │ │ ├── startup-config.ts │ │ │ ├── static-items.ts │ │ │ └── types.ts │ │ ├── definition-scope.ts │ │ ├── documentation-cache.ts │ │ ├── env-manager.ts │ │ ├── exec.ts │ │ ├── file-operations.ts │ │ ├── flag-documentation.ts │ │ ├── flatten.ts │ │ ├── get-lsp-completions.ts │ │ ├── health-check.ts │ │ ├── locations.ts │ │ ├── markdown-builder.ts │ │ ├── maybe.ts │ │ ├── node-types.ts │ │ ├── path-resolution.ts │ │ ├── polyfills.ts │ │ ├── process-env.ts │ │ ├── progress-notification.ts │ │ ├── semantics.ts │ │ ├── snippets.ts │ │ ├── startup.ts │ │ ├── symbol-documentation-builder.ts │ │ ├── translation.ts │ │ ├── tree-sitter.ts │ │ ├── workspace-manager.ts │ │ └── workspace.ts │ ├── virtual-fs.ts │ └── web.ts ├── tests/ │ ├── alias-conversion.test.ts │ ├── analyze-functions.test.ts │ ├── analyzer.test.ts │ ├── cli.test.ts │ ├── code-action.test.ts │ ├── comments-handler.test.ts │ ├── complete-symbol.test.ts │ ├── completion-shell.test.ts │ ├── completion-startup-config.test.ts │ ├── completion-variable-expansion.test.ts │ ├── conditional-execution-diagnostics.test.ts │ ├── definition-location.test.ts │ ├── diagnostics-with-missing-completions.test.ts │ ├── diagnostics.test.ts │ ├── document-highlights.test.ts │ ├── document-test-helpers.ts │ ├── document.test.ts │ ├── embedded-functions-resolution.test.ts │ ├── example-test-workspace-usage.test.ts │ ├── exec.test.ts │ ├── execute-handler.test.ts │ ├── file-operations.test.ts │ ├── fish-symbol-fast-check.test.ts │ ├── fish-symbol.test.ts │ ├── fish-syntax-node.test.ts │ ├── fish_files/ │ │ ├── __fish_complete_docutils.fish │ │ ├── __fish_complete_gpg.fish │ │ ├── __fish_config_interactive.fish │ │ ├── __fish_shared_key_bindings.fish │ │ ├── advanced/ │ │ │ ├── better_variable_scopes.fish │ │ │ ├── inner_functions.fish │ │ │ ├── lots_of_globals.fish │ │ │ ├── multiple_functions.fish │ │ │ ├── variable_scope.fish │ │ │ └── variable_scope_2.fish │ │ ├── errors/ │ │ │ ├── extra_end.fish │ │ │ ├── invalid_pipes.fish │ │ │ ├── missing_end.fish │ │ │ └── variable_expansion_missing_name.fish │ │ ├── fish_config.fish │ │ ├── fish_git_prompt.fish │ │ ├── fish_vi_key_bindings.fish │ │ ├── help.fish │ │ ├── history.fish │ │ ├── huge_file.fish │ │ ├── simple/ │ │ │ ├── all_variable_def_types.fish │ │ │ ├── for_var.fish │ │ │ ├── func_a.fish │ │ │ ├── func_abc.fish │ │ │ ├── function_variable_def.fish │ │ │ ├── global_vs_local.fish │ │ │ ├── inner_function.fish │ │ │ ├── is_chained_return.fish │ │ │ ├── multiple_broken_scopes.fish │ │ │ ├── set_var.fish │ │ │ ├── simple_function.fish │ │ │ └── symbols.fish │ │ ├── small_file.fish │ │ ├── switch_case_test_1.fish │ │ └── umask.fish │ ├── format-aligned-columns.test.ts │ ├── formatting.test.ts │ ├── helpers.ts │ ├── inline-variable.test.ts │ ├── install_scripts/ │ │ └── generate_largest_fish_files.fish │ ├── interactive-buffers.test.ts │ ├── issue-140-complete-command-quoting.test.ts │ ├── logger.test.ts │ ├── main.test.ts │ ├── markdown-builder.test.ts │ ├── node-types.test.ts │ ├── parser.test.ts │ ├── parsing-defintions.test.ts │ ├── parsing-env-values.test.ts │ ├── parsing-export-defintions.test.ts │ ├── parsing-function-with-event.test.ts │ ├── parsing-indent-comments.test.ts │ ├── parsing-string-value.test.ts │ ├── process-env.test.ts │ ├── read-workspace.test.ts │ ├── reference-locations.test.ts │ ├── selection-range.test.ts │ ├── semantic-tokens-helpers.ts │ ├── semantic-tokens.test.ts │ ├── setup-mocks.ts │ ├── snippets.test.ts │ ├── sourced-function-export.test.ts │ ├── startup-workspace.test.ts │ ├── symbol-root-level.test.ts │ ├── temp.ts │ ├── test-comprehensive-utility.test.ts │ ├── test-setup.test.ts │ ├── test-snapshot-functionality.test.ts │ ├── test-workspace-utils.ts │ ├── tree-sitter-fast-check.test.ts │ ├── tree-sitter.test.ts │ ├── unreachable.test.ts │ ├── virtual-file-handling.test.ts │ ├── workspace-manager.test.ts │ ├── workspace-util.ts │ └── workspaces/ │ ├── embedded-functions-resolution/ │ │ ├── functions/ │ │ │ ├── my_test.fish │ │ │ └── other_test.fish │ │ └── test_script.fish │ ├── example_test_src/ │ │ ├── completions/ │ │ │ ├── abbr.fish │ │ │ ├── alias.fish │ │ │ ├── cd.fish │ │ │ ├── cdh.fish │ │ │ ├── diff.fish │ │ │ └── fish_add_path.fish │ │ ├── config.fish │ │ └── functions/ │ │ ├── abbr.fish │ │ ├── alias.fish │ │ ├── cd.fish │ │ ├── cdh.fish │ │ ├── contains_seq.fish │ │ ├── diff.fish │ │ ├── dirh.fish │ │ ├── dirs.fish │ │ ├── down-or-search.fish │ │ ├── edit_command_buffer.fish │ │ ├── export.fish │ │ └── fish_add_path.fish │ ├── incorrect-permissions-indexing/ │ │ └── file.fish │ ├── semantic-tokens-simple-workspace/ │ │ ├── basic.fish │ │ ├── commands.fish │ │ ├── completions/ │ │ │ ├── deployctl.fish │ │ │ └── source_fish.fish │ │ ├── diagnostics.fish │ │ ├── functions.fish │ │ ├── keywords.fish │ │ ├── mixed.fish │ │ ├── operators.fish │ │ └── variables.fish │ ├── workspace_1/ │ │ └── fish/ │ │ ├── completions/ │ │ │ └── exa.fish │ │ ├── config.fish │ │ └── functions/ │ │ ├── func-inner.fish │ │ ├── test-func.fish │ │ ├── test-rename-1.fish │ │ ├── test-rename-2.fish │ │ └── test-variable-renames.fish │ └── workspace_semantic-tokens/ │ └── test-operator-tokens.fish ├── tsconfig.eslint.json ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "projectName": "fish-lsp", "projectOwner": "ndonfris", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 50, "commit": false, "commitConvention": "eslint", "contributors": [ { "login": "ndonfris", "name": "nick", "avatar_url": "https://avatars.githubusercontent.com/u/49458459?v=4", "profile": "https://github.com/ndonfris", "contributions": [ "code" ] }, { "login": "mimikun", "name": "mimikun", "avatar_url": "https://avatars.githubusercontent.com/u/13450321?v=4", "profile": "https://github.com/mimikun", "contributions": [ "code" ] }, { "login": "jpaju", "name": "Jaakko Paju", "avatar_url": "https://avatars.githubusercontent.com/u/36770267?v=4", "profile": "https://github.com/jpaju", "contributions": [ "code" ] }, { "login": "shaleh", "name": "Sean Perry", "avatar_url": "https://avatars.githubusercontent.com/u/1377996?v=4", "profile": "https://github.com/shaleh", "contributions": [ "code" ] }, { "login": "cova-fe", "name": "Fabio Coatti", "avatar_url": "https://avatars.githubusercontent.com/u/385249?v=4", "profile": "https://mastodon.online/@cova", "contributions": [ "code" ] }, { "login": "PeterCardenas", "name": "Peter Cardenas", "avatar_url": "https://avatars.githubusercontent.com/u/16930781?v=4", "profile": "https://github.com/PeterCardenas", "contributions": [ "code" ] }, { "login": "petertriho", "name": "Peter Tri Ho", "avatar_url": "https://avatars.githubusercontent.com/u/7420227?v=4", "profile": "https://github.com/petertriho", "contributions": [ "code" ] }, { "login": "bnwa", "name": "bnwa", "avatar_url": "https://avatars.githubusercontent.com/u/74591246?v=4", "profile": "https://github.com/bnwa", "contributions": [ "code" ] }, { "login": "branchvincent", "name": "Branch Vincent", "avatar_url": "https://avatars.githubusercontent.com/u/19800529?v=4", "profile": "https://github.com/branchvincent", "contributions": [ "code" ] }, { "login": "devsunb", "name": "Jaeseok Lee", "avatar_url": "https://avatars.githubusercontent.com/u/23169202?v=4", "profile": "https://github.com/devsunb", "contributions": [ "code" ] }, { "login": "ClanEver", "name": "ClanEver", "avatar_url": "https://avatars.githubusercontent.com/u/73160783?v=4", "profile": "https://github.com/ClanEver", "contributions": [ "code" ] }, { "login": "ndegruchy", "name": "Nathan DeGruchy", "avatar_url": "https://avatars.githubusercontent.com/u/52262673?v=4", "profile": "https://degruchy.org/", "contributions": [ "code" ] }, { "login": "TeddyHuang-00", "name": "Nan Huang", "avatar_url": "https://avatars.githubusercontent.com/u/64199650?v=4", "profile": "https://teddyhuang-00.github.io/", "contributions": [ "code" ] }, { "login": "unlimitedsola", "name": "Sola", "avatar_url": "https://avatars.githubusercontent.com/u/3632663?v=4", "profile": "https://github.com/unlimitedsola", "contributions": [ "code" ] }, { "login": "jose-elias-alvarez", "name": "Jose Alvarez", "avatar_url": "https://avatars.githubusercontent.com/u/54108223?v=4", "profile": "https://github.com/jose-elias-alvarez", "contributions": [ "code" ] }, { "login": "gaborbernat", "name": "Bernát Gábor", "avatar_url": "https://avatars.githubusercontent.com/u/690238?v=4", "profile": "https://www.bernat.tech/", "contributions": [ "code" ] } ], "contributorsPerLine": 7, "linkToUsage": false } ================================================ FILE: .github/CODEOWNERS ================================================ * @ndonfris ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: ndonfris # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] # patreon: # Replace with a single Patreon username # open_collective: # Replace with a single Open Collective username # ko_fi: # Replace with a single Ko-fi username # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry # liberapay: # Replace with a single Liberapay username # issuehunt: # Replace with a single IssueHunt username # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry # polar: # Replace with a single Polar username buy_me_a_coffee: ndonfris # Replace with a single Buy Me a Coffee username # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.md ================================================ --- name: Bug Report about: Report a bug you're experiencing title: BUG labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior. **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Please complete the following information:** - OS: [e.g. Ubuntu, macOS, etc...] `uname -o` - yarn version: [e.g. yarn@1.22.22] `yarn --version` - node version: [e.g., node@20.0.0] `node --version` - fish version [e.g., fish@3.7.1] `fish --version` - fish-lsp version [e.g, fish-lsp@1.0.4] `fish-lsp --version` > You can run the following in your shell: ```fish echo "OS NAME: $(uname -o)" echo "YARN VERSION: $(yarn --version)" echo "NODE VERSION: $(node --version)" echo "FISH VERSION: $(fish --version)" echo "FISH-LSP VERSION: $(fish-lsp --version)" ``` **Additional context** Any other context about the problem here. - fish-lsp configuration (Include if relevant to the issue) - relevant `logs.txt` output: `cat (fish-lsp info --logs-file)` ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/check-npm-release.yml ================================================ # Daily check that fish-lsp NPM package installation # and basic commands are working name: Check NPM Release on: schedule: - cron: '20 2 * * *' workflow_dispatch: # Allow manual triggering jobs: verify-npm-package: name: Verify fish-lsp NPM Package runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest] permissions: contents: read security-events: write actions: read steps: - name: Install Fish Shell uses: fish-actions/install-fish@v1.2.0 - name: Check which fish version run: fish --version # shell: fish {0} # to use fish shell for a step - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22.14.0 - name: Install npm package run: npm install -g fish-lsp@latest - name: Check fish-lsp exists `which fish-lsp` run: which fish-lsp - name: Check binary `fish-lsp --help` run: fish-lsp --help - name: Check version `fish-lsp --version` run: fish-lsp --version - name: Check completions `fish-lsp complete` run: fish-lsp complete - name: Check info `fish-lsp info` run: fish-lsp info - name: Check env `fish-lsp env --show` run: fish-lsp env --show - name: Check startup time `fish-lsp info --time-startup` run: fish-lsp info --time-startup ================================================ FILE: .github/workflows/ci.yml ================================================ ## INSTALL, BUILD and LINT a local clone of the repository, with the recommended dependencies. ## Will run on every push to master, every PR to master, and once a day at 2:20 UTC. ## Also allow manual triggering. name: CI Pipeline on: push: branches: - master pull_request: branches: - master schedule: - cron: '20 2 * * *' # every day at 2:20 UTC workflow_dispatch: # Allow manual triggering jobs: ci: name: (master) CI Pipeline - install, build & lint runs-on: ubuntu-latest permissions: contents: read security-events: write actions: read steps: - name: Checkout Code uses: actions/checkout@v4 - name: Install Fish Shell uses: fish-actions/install-fish@v1.2.0 - name: Set up Node.js uses: actions/setup-node@v4 with: # node-version: 22.14.0 node-version-file: .nvmrc - name: Install Yarn shell: fish {0} run: npm install -g yarn@1.22.22 - name: Install Dependencies shell: fish {0} run: yarn install - name: Build Development shell: fish {0} run: yarn build - name: Check Binary shell: fish {0} run: fish-lsp --help - name: Run Lint shell: fish {0} run: yarn lint:fix ================================================ FILE: .github/workflows/test-npm-package.yml ================================================ ## Test that the built npm package can be successfully installed and works correctly ## This workflow builds the package, packs it, installs it globally, and runs verification commands name: Test NPM Package Installation on: push: branches: - master workflow_dispatch: inputs: timestamp: description: 'Build timestamp (for reproducible builds)' required: false default: '' pull_request: paths: - 'src/**' - 'package.json' - 'scripts/build.ts' - '.github/workflows/test-npm-package.yml' jobs: test-npm-package: name: Test NPM Package (Node ${{ matrix.node-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} permissions: contents: read strategy: fail-fast: false matrix: os: - ubuntu-latest - macos-latest node-version: - '20' # Minimum supported version - '22' # Current LTS - '24' # Latest stable steps: - name: Checkout Code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: Install Fish Shell (v4.x) uses: fish-actions/install-fish@v1.2.0 - name: Generate Timestamp if Not Provided id: timestamp run: | if [ -z "${{ github.event.inputs.timestamp }}" ]; then echo "SOURCE_DATE_EPOCH=$(date +%s)" >> $GITHUB_ENV else echo "SOURCE_DATE_EPOCH=${{ github.event.inputs.timestamp }}" >> $GITHUB_ENV fi - name: Install Yarn shell: fish {0} run: npm install -g yarn@1.22.22 - name: Display Build Timestamp shell: fish {0} run: | echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" echo "Human-readable: "(date -d @$SOURCE_DATE_EPOCH 2>/dev/null || date -r $SOURCE_DATE_EPOCH 2>/dev/null || echo "N/A") - name: Install Dependencies shell: fish {0} run: yarn install - name: Build for NPM shell: fish {0} run: yarn build:npm - name: Pack the Package shell: fish {0} run: yarn package - name: Upload Package Artifact if: github.event_name != 'workflow_dispatch' || !env.ACT uses: actions/upload-artifact@v4 with: name: fish-lsp-package-node${{ matrix.node-version }}-${{ matrix.os }} path: fish-lsp.tgz retention-days: 7 if-no-files-found: error - name: Install Package Globally shell: fish {0} run: | set -l package_file (ls fish-lsp.tgz | head -n 1) echo "Installing package: $package_file" npm install -g $package_file - name: Verify fish-lsp binary exists run: which fish-lsp - name: Test - fish-lsp --help run: fish-lsp --help - name: Test - fish-lsp --version run: fish-lsp --version - name: Test - fish-lsp info run: fish-lsp info - name: Test - fish-lsp info --build-time run: fish-lsp info --build-time - name: Test - fish-lsp info --path run: fish-lsp info --path - name: Test - fish-lsp env run: fish-lsp env - name: Test - fish-lsp info --time-startup run: fish-lsp info --time-startup - name: Test - fish-lsp complete run: fish-lsp complete - name: Test - fish-lsp complete | fish --no-execute run: fish-lsp complete | fish --no-execute - name: Test - fish-lsp env --show-default | fish --no-execute run: fish-lsp env --show-default | fish --no-execute - name: Test - fish-lsp start --dump run: fish-lsp start --dump - name: Cleanup - Uninstall Package if: always() run: npm uninstall -g fish-lsp ================================================ FILE: .gitignore ================================================ .DS_Store .vim *.log *.tsbuildinfo logs.txt wikis tests/staging *.tgz *.tsbuildinfo .editorconfig .gitattributes coverage .bun dist build *.cast *.gif *.txt # ignore all build artifacts out node_modules bin lib dist !bin/fish-lsp scripts/build-with-bun.sh scripts/build-binary.ts scripts/debug.fish scripts/build-release.fish release-assets tsconfig.types.json *.d.ts temp-types tests/workspaces/*.snapshot temp-embedded-assets tests/workspaces/example_test_src_1 !docs/CHANGELOG.md !docs/CONTRIBUTING.md !docs/ROADMAP.md !docs/MAN_FILE.md docs/* ================================================ FILE: .husky/commit-msg ================================================ ## .husky/commit-msg # yarn commitlint --extends '@commitlint/config-conventional' --edit $1 yarn commitlint --extends '@commitlint/config-conventional' --edit $1 --git-log-args='first-parent cherry-pick' ================================================ FILE: .husky/post-checkout ================================================ # Only run yarn install if this was a branch checkout (not file checkout) # $3 is 1 for branch checkout, 0 for file checkout if [ "$3" = "1" ]; then yarn install fi ================================================ FILE: .husky/post-merge ================================================ yarn ================================================ FILE: .husky/pre-commit ================================================ ##.husky/pre-commit yarn run lint-staged ================================================ FILE: .husky/prepare-commit-msg ================================================ #!/usr/bin/env sh # . "$(dirname -- "$0")/_/husky.sh" cp "$1" "/tmp/fish-lsp-last-commit-msg-$(date +%Y%m%d-%H%M%S).msg" ================================================ FILE: .nvmrc ================================================ v22.14.0 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at donfris.nick@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: LICENSE.md ================================================ Copyright (c) 2022-2025 Nick Donfris and others 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================
>= v0.8.x)###### :package: Default Values:
```fish # $fish_lsp_enabled_handlersfish-lsp env --show-default# Enables the fish-lsp handlers. By default, all stable handlers are enabled. # (Options: 'complete', 'hover', 'rename', 'definition', 'implementation', # 'reference', 'logger', 'formatting', 'formatRange', # 'typeFormatting', 'codeAction', 'codeLens', 'folding', # 'selectionRange', 'signature', 'executeCommand', 'inlayHint', # 'highlight', 'diagnostic', 'popups', 'semanticTokens') # (Default: []) set -gx fish_lsp_enabled_handlers # $fish_lsp_disabled_handlers # Disables the fish-lsp handlers. By default, non-stable handlers are disabled. # (Options: 'complete', 'hover', 'rename', 'definition', 'implementation', # 'reference', 'logger', 'formatting', 'formatRange', # 'typeFormatting', 'codeAction', 'codeLens', 'folding', # 'selectionRange', 'signature', 'executeCommand', 'inlayHint', # 'highlight', 'diagnostic', 'popups', 'semanticTokens') # (Default: []) set -gx fish_lsp_disabled_handlers # $fish_lsp_commit_characters # Array of the completion expansion characters. # Single letter values only. # Commit characters are used to select completion items, as shortcuts. # (Example Options: '.', ',', ';', ':', '(', ')', '[', ']', '{', '}', '<', # '>', ''', '"', '=', '+', '-', '/', '\', '|', '&', '%', # '$', '#', '@', '!', '?', '*', '^', '`', '~', '\t', ' ') # (Default: ['\t', ';', ' ']) set -gx fish_lsp_commit_characters '\t' ';' ' ' # $fish_lsp_log_file # A path to the fish-lsp's logging file. Empty string disables logging. # (Example Options: '/tmp/fish_lsp.log', '~/path/to/fish_lsp/logs.txt') # (Default: '') set -gx fish_lsp_log_file '' # $fish_lsp_log_level # The logging severity level for displaying messages in the log file. # (Options: 'debug', 'info', 'warning', 'error', 'log') # (Default: '') set -gx fish_lsp_log_level '' # $fish_lsp_all_indexed_paths # The fish file paths to include in the fish-lsp's startup indexing, as workspaces. # Order matters (usually place `$__fish_config_dir` before `$__fish_data_dir`). # (Example Options: '$HOME/.config/fish', '/usr/share/fish', # '$__fish_config_dir', '$__fish_data_dir') # (Default: ['$__fish_config_dir', '$__fish_data_dir']) set -gx fish_lsp_all_indexed_paths "$__fish_config_dir" "$__fish_data_dir" # $fish_lsp_modifiable_paths # The fish file paths, for workspaces where global symbols can be renamed by the user. # (Example Options: '/usr/share/fish', '$HOME/.config/fish', # '$__fish_data_dir', '$__fish_config_dir') # (Default: ['$__fish_config_dir']) set -gx fish_lsp_modifiable_paths "$__fish_config_dir" # $fish_lsp_diagnostic_disable_error_codes # The diagnostics error codes to disable from the fish-lsp's diagnostics. # (Options: 1001, 1002, 1003, 1004, 1005, 2001, 2002, 2003, 2004, 3001, 3002, # 3003, 4001, 4002, 4003, 4004, 4005, 4006, 4007, 4008, 5001, 5555, # 6001, 7001, 8001, 9999) # (Default: []) set -gx fish_lsp_diagnostic_disable_error_codes # $fish_lsp_max_diagnostics # The maximum number of diagnostics to return per file. # Using value `0` means unlimited diagnostics. # To entirely disable diagnostics use `fish_lsp_disabled_handlers` # (Example Options: 0, 10, 25, 50, 100, 250) # (Default: 0) set -gx fish_lsp_max_diagnostics 0 # $fish_lsp_enable_experimental_diagnostics # Enables the experimental diagnostics feature, using `fish --no-execute`. # This feature will enable the diagnostic error code 9999 (disabled by default). # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_enable_experimental_diagnostics false # $fish_lsp_strict_conditional_command_warnings # Diagnostic `3002` includes/excludes conditionally chained commands to explicitly check existence. # ENABLED EXAMPLE: `command -q ls && command ls || echo 'no ls'` # DISABLED EXAMPLE: `command ls || echo 'no ls'` # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_strict_conditional_command_warnings false # $fish_lsp_prefer_builtin_fish_commands # Show diagnostic `2004` which warns the user when they are using a recognized external command that can be replaced by an equivalent fish builtin command. # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_prefer_builtin_fish_commands false # $fish_lsp_allow_fish_wrapper_functions # Show warnings when `alias`, `export`, etc... are used instead of their equivalent fish builtin commands. # Some commands will provide quick-fixes to convert this diagnostic to its equivalent fish command. # Diagnostic `2002` is shown when this setting is false, and hidden when true. # (Options: 'true', 'false') # (Default: 'true') set -gx fish_lsp_allow_fish_wrapper_functions true # $fish_lsp_require_autoloaded_functions_to_have_description # Show warning diagnostic `4008` when an autoloaded function definition does not have a description `function -d/--description '...'; end;` # (Options: 'true', 'false') # (Default: 'true') set -gx fish_lsp_require_autoloaded_functions_to_have_description true # $fish_lsp_max_background_files # The maximum number of background files to read into buffer on startup. # (Example Options: 100, 250, 500, 1000, 5000, 10000) # (Default: 10000) set -gx fish_lsp_max_background_files 10000 # $fish_lsp_show_client_popups # Should the client receive pop-up window notification requests from the fish-lsp server? # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_show_client_popups true # $fish_lsp_single_workspace_support # Try to limit the fish-lsp's workspace searching to only the current workspace open. # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_single_workspace_support false # $fish_lsp_ignore_paths # Glob paths to never search when indexing their parent folder # (Example Options: '**/.git/**', '**/node_modules/**', '**/vendor/**', # '**/__pycache__/**', '**/docker/**', # '**/containerized/**', '**/*.log', '**/tmp/**') # (Default: ['**/.git/**', '**/node_modules/**', '**/containerized/**', # '**/docker/**']) set -gx fish_lsp_ignore_paths '**/.git/**' '**/node_modules/**' '**/containerized/**' '**/docker/**' # $fish_lsp_max_workspace_depth # The maximum depth for the lsp to search when starting up. # (Example Options: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20) # (Default: 5) set -gx fish_lsp_max_workspace_depth 3 # $fish_lsp_fish_path # A path to the fish executable to use exposing fish binary to use in server's spawned child_processes. # Typically, this is used in the language-client's `FishServer.initialize(connection, InitializeParams.initializationOptions)`, NOT as an environment variable # (Example Options: 'fish', '/usr/bin/fish', '/usr/.local/bin/fish', # '~/.local/bin/fish') # (Default: '') set -gx fish_lsp_fish_path 'fish' ```
###### :gear: Complete Configuration Template:
```fish # $fish_lsp_enabled_handlersfish-lsp env --create# Enables the fish-lsp handlers. By default, all stable handlers are enabled. # (Options: 'complete', 'hover', 'rename', 'definition', 'implementation', # 'reference', 'logger', 'formatting', 'formatRange', # 'typeFormatting', 'codeAction', 'codeLens', 'folding', # 'selectionRange', 'signature', 'executeCommand', 'inlayHint', # 'highlight', 'diagnostic', 'popups', 'semanticTokens') # (Default: []) set -gx fish_lsp_enabled_handlers # $fish_lsp_disabled_handlers # Disables the fish-lsp handlers. By default, non-stable handlers are disabled. # (Options: 'complete', 'hover', 'rename', 'definition', 'implementation', # 'reference', 'logger', 'formatting', 'formatRange', # 'typeFormatting', 'codeAction', 'codeLens', 'folding', # 'selectionRange', 'signature', 'executeCommand', 'inlayHint', # 'highlight', 'diagnostic', 'popups', 'semanticTokens') # (Default: []) set -gx fish_lsp_disabled_handlers # $fish_lsp_commit_characters # Array of the completion expansion characters. # Single letter values only. # Commit characters are used to select completion items, as shortcuts. # (Example Options: '.', ',', ';', ':', '(', ')', '[', ']', '{', '}', '<', # '>', ''', '"', '=', '+', '-', '/', '\', '|', '&', '%', # '$', '#', '@', '!', '?', '*', '^', '`', '~', '\t', ' ') # (Default: ['\t', ';', ' ']) set -gx fish_lsp_commit_characters # $fish_lsp_log_file # A path to the fish-lsp's logging file. Empty string disables logging. # (Example Options: '/tmp/fish_lsp.log', '~/path/to/fish_lsp/logs.txt') # (Default: '') set -gx fish_lsp_log_file # $fish_lsp_log_level # The logging severity level for displaying messages in the log file. # (Options: 'debug', 'info', 'warning', 'error', 'log') # (Default: '') set -gx fish_lsp_log_level # $fish_lsp_all_indexed_paths # The fish file paths to include in the fish-lsp's startup indexing, as workspaces. # Order matters (usually place `$__fish_config_dir` before `$__fish_data_dir`). # (Example Options: '$HOME/.config/fish', '/usr/share/fish', # '$__fish_config_dir', '$__fish_data_dir') # (Default: ['$__fish_config_dir', '$__fish_data_dir']) set -gx fish_lsp_all_indexed_paths # $fish_lsp_modifiable_paths # The fish file paths, for workspaces where global symbols can be renamed by the user. # (Example Options: '/usr/share/fish', '$HOME/.config/fish', # '$__fish_data_dir', '$__fish_config_dir') # (Default: ['$__fish_config_dir']) set -gx fish_lsp_modifiable_paths # $fish_lsp_diagnostic_disable_error_codes # The diagnostics error codes to disable from the fish-lsp's diagnostics. # (Options: 1001, 1002, 1003, 1004, 1005, 2001, 2002, 2003, 2004, 3001, 3002, # 3003, 4001, 4002, 4003, 4004, 4005, 4006, 4007, 4008, 5001, 5555, # 6001, 7001, 8001, 9999) # (Default: []) set -gx fish_lsp_diagnostic_disable_error_codes # $fish_lsp_max_diagnostics # The maximum number of diagnostics to return per file. # Using value `0` means unlimited diagnostics. # To entirely disable diagnostics use `fish_lsp_disabled_handlers` # (Example Options: 0, 10, 25, 50, 100, 250) # (Default: 0) set -gx fish_lsp_max_diagnostics # $fish_lsp_enable_experimental_diagnostics # Enables the experimental diagnostics feature, using `fish --no-execute`. # This feature will enable the diagnostic error code 9999 (disabled by default). # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_enable_experimental_diagnostics # $fish_lsp_strict_conditional_command_warnings # Diagnostic `3002` includes/excludes conditionally chained commands to explicitly check existence. # ENABLED EXAMPLE: `command -q ls && command ls || echo 'no ls'` # DISABLED EXAMPLE: `command ls || echo 'no ls'` # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_strict_conditional_command_warnings # $fish_lsp_prefer_builtin_fish_commands # Show diagnostic `2004` which warns the user when they are using a recognized external command that can be replaced by an equivalent fish builtin command. # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_prefer_builtin_fish_commands # $fish_lsp_allow_fish_wrapper_functions # Show warnings when `alias`, `export`, etc... are used instead of their equivalent fish builtin commands. # Some commands will provide quick-fixes to convert this diagnostic to its equivalent fish command. # Diagnostic `2002` is shown when this setting is false, and hidden when true. # (Options: 'true', 'false') # (Default: 'true') set -gx fish_lsp_allow_fish_wrapper_functions # $fish_lsp_require_autoloaded_functions_to_have_description # Show warning diagnostic `4008` when an autoloaded function definition does not have a description `function -d/--description '...'; end;` # (Options: 'true', 'false') # (Default: 'true') set -gx fish_lsp_require_autoloaded_functions_to_have_description # $fish_lsp_max_background_files # The maximum number of background files to read into buffer on startup. # (Example Options: 100, 250, 500, 1000, 5000, 10000) # (Default: 10000) set -gx fish_lsp_max_background_files # $fish_lsp_show_client_popups # Should the client receive pop-up window notification requests from the fish-lsp server? # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_show_client_popups # $fish_lsp_single_workspace_support # Try to limit the fish-lsp's workspace searching to only the current workspace open. # (Options: 'true', 'false') # (Default: 'false') set -gx fish_lsp_single_workspace_support # $fish_lsp_ignore_paths # Glob paths to never search when indexing their parent folder # (Example Options: '**/.git/**', '**/node_modules/**', '**/vendor/**', # '**/__pycache__/**', '**/docker/**', # '**/containerized/**', '**/*.log', '**/tmp/**') # (Default: ['**/.git/**', '**/node_modules/**', '**/containerized/**', # '**/docker/**']) set -gx fish_lsp_ignore_paths # $fish_lsp_max_workspace_depth # The maximum depth for the lsp to search when starting up. # (Example Options: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20) # (Default: 5) set -gx fish_lsp_max_workspace_depth # $fish_lsp_fish_path # A path to the fish executable to use exposing fish binary to use in server's spawned child_processes. # Typically, this is used in the language-client's `FishServer.initialize(connection, InitializeParams.initializationOptions)`, NOT as an environment variable # (Example Options: 'fish', '/usr/bin/fish', '/usr/.local/bin/fish', # '~/.local/bin/fish') # (Default: '') set -gx fish_lsp_fish_path ```
###### :floppy_disk: Formatting as JSON:
```json { "fish_lsp_enabled_handlers": [], "fish_lsp_disabled_handlers": [], "fish_lsp_commit_characters": [ "\t", ";", " " ], "fish_lsp_log_file": "", "fish_lsp_log_level": "", "fish_lsp_all_indexed_paths": [ "$__fish_config_dir", "$__fish_data_dir" ], "fish_lsp_modifiable_paths": [ "$__fish_config_dir" ], "fish_lsp_diagnostic_disable_error_codes": [], "fish_lsp_max_diagnostics": 0, "fish_lsp_enable_experimental_diagnostics": false, "fish_lsp_strict_conditional_command_warnings": false, "fish_lsp_prefer_builtin_fish_commands": false, "fish_lsp_allow_fish_wrapper_functions": true, "fish_lsp_require_autoloaded_functions_to_have_description": true, "fish_lsp_max_background_files": 10000, "fish_lsp_show_client_popups": true, "fish_lsp_single_workspace_support": false, "fish_lsp_ignore_paths": [ "**/.git/**", "**/node_modules/**", "**/containerized/**", "**/docker/**" ], "fish_lsp_max_workspace_depth": 3, "fish_lsp_fish_path": "fish" } ```fish-lsp env --show-default --json
For language clients that import the source code directly and manually connect with the server (e.g., [VSCode](https://github.com/ndonfris/vscode-fish-lsp/blob/4aa63803a0d0a65ceabf164eaeb5a3e360662ef9/package.json#L136)), passing the environment configuration through the [`initializeParams.initializationOptions`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initializeParams) is also possible. #### Command Flags Both the flags `--enable` and `--disable` are provided on the `fish-lsp start` subcommand. __Default configuration enables all stable server handlers__. ```fish # displays what handlers are enabled. Removing the dump flag will run the server. fish-lsp start --disable complete signature --dump ``` #### Further Server Configuration Any [flags](#command-flags) will overwrite their corresponding [environment variables](#environment-variables), if both are seen for the `fish-lsp` process. For this reason, it is encouraged to wrap any non-standard behavior of the `fish-lsp` in [functions](https://fishshell.com/docs/current/language.html#functions) or [aliases](https://fishshell.com/docs/current/language.html#defining-aliases). Due to the vast possibilities this project aims to support in the fish shell, [sharing useful configurations is highly encouraged](https://github.com/ndonfris/fish-lsp/discussions). ##### Project Specific configuration via dot-env If you are using the environment variables, or an alias to start the server from a shell instance, you can also use a `.env` file to set project specific overrides. This is not directly supported by the server, but can be achieved using the variety of dotenv tools available.[\[1\]](https://github.com/berk-karaal/loadenv.fish)[\[2\]](https://direnv.net)[\[3\]](https://github.com/jdx/mise)[\[4\]](https://github.com/hyperupcall/autoenv) ##### Configuration via Disable Comments###### :jigsaw: Writing current values to
```fish ## clear the current fish-lsp configuration ## >_ fish-lsp env --names-only | string split \n | read -e $name; ## grab only specific variables fish-lsp env --show-default --only fish_lsp_all_indexed_paths fish_lsp_diagnostic_disable_error_codes | source ## Write the current fish-lsp configuration to ~/.config/fish/conf.d/fish-lsp.fish fish-lsp env --show --confd > ~/.config/fish/conf.d/fish-lsp.fish ```~/.config/fish/conf.d/fish-lsp.fish
nick 💻 |
mimikun 💻 |
Jaakko Paju 💻 |
Sean Perry 💻 |
Fabio Coatti 💻 |
Peter Cardenas 💻 |
Peter Tri Ho 💻 |
bnwa 💻 |
Branch Vincent 💻 |
Jaeseok Lee 💻 |
ClanEver 💻 |
Nathan DeGruchy 💻 |
Nan Huang 💻 |
Sola 💻 |
Jose Alvarez 💻 |
Bernát Gábor 💻 |
tag inside -
// const variableCodeElement = dt.querySelector('code');
// const nameWithDollar = variableCodeElement?.textContent?.trim() || '';
//
// // Clean up the name (remove leading '$')
// const name = nameWithDollar.startsWith('$') ? nameWithDollar.substring(1) : nameWithDollar;
//
// // The description is in the immediately following
- sibling
// const dd = dt.nextElementSibling;
// let description = '';
//
// if (dd && dd.tagName === 'DD') {
// // Get the full text content of
- and normalize whitespace
// description = dd.textContent?.trim().replace(/\s+/g, ' ') || '';
// }
//
// if (name && description) {
// specialVariables.push({
// name: name,
// description: description,
// });
// }
// });
const sectionLabelSeparator = specialVariables.findIndex(item => item.name === '_');
if (showArgs['env-variables'].seen && showArgs['special-variables'].seen && keys.length === 2) {
return specialVariables;
}
if (showArgs['env-variables'].seen && keys.length === 1) {
return specialVariables.slice(sectionLabelSeparator);
}
if (showArgs['special-variables'].seen && keys.length === 1) {
return specialVariables.slice(0, sectionLabelSeparator);
}
} catch (error) {
console.error('Error fetching special variables:', error);
return [];
}
}
function stripQuotes(value: string): string {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function tokenizeDefinition(line: string): string[] {
const tokens: string[] = [];
let current = '';
let quote: string | null = null;
for (let i = 0; i < line.length; i++) {
const char = line[i]!;
if (quote) {
if (char === '\\' && quote === '"' && i + 1 < line.length) {
current += line[i + 1]!;
i++;
continue;
}
if (char === quote) {
tokens.push(current);
current = '';
quote = null;
continue;
}
current += char;
continue;
}
if (char === '"' || char === '\'') {
if (current) {
tokens.push(current);
current = '';
}
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (current) tokens.push(current);
return tokens.filter(Boolean);
}
function parseFunctionLine(line: string): { name: string; flags: string[]; description?: string } | null {
const match = line.match(/^\s*function\s+(.+)$/);
if (!match) return null;
const tokens = tokenizeDefinition(match[1]!.trim());
if (tokens.length === 0) return null;
const name = tokens.shift()!;
const flags: string[] = [];
let description: string | undefined;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]!;
if (!token.startsWith('-')) continue;
let combined = token;
const next = tokens[i + 1];
if (next && !next.startsWith('-')) {
combined = `${token} ${next}`;
i++;
if ((token === '--description' || token === '-d') && next) {
description = stripQuotes(next);
}
}
flags.push(combined.trim());
}
return { name, flags, description };
}
function resolveFishDataDir(): string | null {
const candidateEnv = process.env.__fish_data_dir;
const candidates = [
candidateEnv,
(() => {
try {
const result = execSync('fish -c "printf %s $__fish_data_dir"', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
return result.trim() || null;
} catch {
return null;
}
})(),
'/usr/share/fish',
'/usr/local/share/fish',
].filter((value): value is string => Boolean(value));
for (const candidate of candidates) {
const functionsDir = path.join(candidate, 'functions');
if (fsSync.existsSync(functionsDir)) {
return candidate;
}
}
return null;
}
async function fetchFishFunctions(): Promise
{
const dataDir = resolveFishDataDir();
if (!dataDir) {
console.error('Unable to locate $__fish_data_dir. Is fish installed?');
return [];
}
const functionsDir = path.join(dataDir, 'functions');
const entries = await fs.readdir(functionsDir, { withFileTypes: true });
const results = new Map();
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.fish')) continue;
const filePath = path.join(functionsDir, entry.name);
const fileContents = await fs.readFile(filePath, 'utf8');
const definitionLine = fileContents.split(/\r?\n/).find(line => line.trim().startsWith('function '));
if (!definitionLine) continue;
const parsed = parseFunctionLine(definitionLine);
if (!parsed) continue;
const relativeFunctionsPath = path.relative(path.join(dataDir, 'functions'), filePath).replace(/\\/g, '/');
const fileReference = relativeFunctionsPath
? `$__fish_data_dir/functions/${relativeFunctionsPath}`
: '$__fish_data_dir/functions';
results.set(parsed.name, {
name: parsed.name,
file: fileReference,
flags: parsed.flags,
description: parsed.description,
});
}
return [...results.values()].sort((a, b) => a.name.localeCompare(b.name));
}
async function fetchDataset(target: DatasetType): Promise {
switch (target) {
case 'commands':
return fetchFishCommands();
case 'functions':
return fetchFishFunctions();
case 'special-variables':
return fetchSpecialVariables('special-variables');
case 'env-variables':
return fetchSpecialVariables('env-variables');
default:
return [];
}
}
async function main() {
try {
const snippetsDir = path.join(process.cwd(), 'src', 'snippets');
const requestedTargets: DatasetType[] = hasShowArg ? [...showArgsArray] as DatasetType[] : ['commands'];
if (requestedTargets.length === 0) {
requestedTargets.push('commands');
}
const uniqueTargets = [...new Set(requestedTargets)];
if (uniqueTargets.length === 0) {
console.error('No action specified. Use --help for usage.');
return;
}
for (const target of uniqueTargets) {
const dataset = await fetchDataset(target);
if (!dataset || dataset.length === 0) {
console.error(`No data found for "${target}".`);
continue;
}
const jsonOutput = JSON.stringify(dataset, null, 2);
// Handle --diff flag
if (diffOutput) {
const outputPath = path.join(snippetsDir, datasetConfig[target].outputFile);
try {
const existingContent = await fs.readFile(outputPath, 'utf8');
const existingJson = JSON.parse(existingContent);
const existingFormatted = JSON.stringify(existingJson, null, 2);
if (existingFormatted === jsonOutput) {
console.error(`No changes for ${target}`);
} else {
console.error(`\n=== Diff for ${target} (${outputPath}) ===`);
const existingLines = existingFormatted.split('\n');
const newLines = jsonOutput.split('\n');
const maxLines = Math.max(existingLines.length, newLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = existingLines[i] || '';
const newLine = newLines[i] || '';
if (oldLine !== newLine) {
if (oldLine) console.error(`- ${oldLine}`);
if (newLine) console.error(`+ ${newLine}`);
}
}
}
} catch (error) {
console.error(`No existing file for ${target} at ${outputPath}`);
console.error(`New content would be:\n${jsonOutput}`);
}
continue;
}
if (!writeOutput) {
process.stdout.write(jsonOutput + '\n');
continue;
}
try {
await fs.access(snippetsDir);
} catch (error) {
console.error(`Error: Directory '${snippetsDir}' does not exist.`);
console.error('Please create the directory first before using --write option.');
process.exit(1);
}
const outputPath = path.join(snippetsDir, datasetConfig[target].outputFile);
await fs.writeFile(outputPath, jsonOutput);
console.error(`${target} data written to ${outputPath}`);
}
} catch (error) {
console.error('General Error:', error);
process.exit(1);
}
}
main();
================================================
FILE: scripts/publish-nightly.fish
================================================
#!/usr/bin/env fish
# ┌──────────────────────────────┐
# │ Imported variables/functions │
# └──────────────────────────────┘
source ./scripts/fish/continue-or-exit.fish
source ./scripts/fish/pretty-print.fish
source ./scripts/fish/utils.fish
# ┌─────────────────┐
# │ Parse arguments │
# └─────────────────┘
argparse \
-x c,d -x c,bump-pre -x c,skip-confirm -x i,skip-confirm \
h/help c/complete d/dry-run bump-pre skip-confirm i/interactive -- $argv
or exit 1
# ┌──────────────────────┐
# │ Execution mode setup │
# └──────────────────────┘
set -g DRY_RUN (set -q _flag_dry_run && echo 'true' || echo 'false')
set -g SKIP_CONFIRM (set -q _flag_skip_confirm && echo 'true' || echo 'false')
set -g INTERACTIVE (set -q _flag_interactive && echo 'true' || echo 'false')
# ┌────────────────────────────────────┐
# │ handle flags that cause early exit │
# └────────────────────────────────────┘
# Help flags: `-h` or `--help`
if set -q _flag_help
echo -e "Usage: publish-nightly.fish [--dry-run] [--skip-confirm] [-c | --complete] [-h | --help] [--bump-pre]\n"
echo -e "Publishes current `package.json` version to npm with nightly tags"
echo -e "\nOptions:\n"
echo -e " -h, --help Show this help message and exit"
echo -e " -d, --dry-run Show what would be done without making changes"
echo -e " --skip-confirm Skip all confirmation prompts"
echo -e " -c, --complete Show completion commands for this script"
echo -e " --bump-pre Bump the preminor version and exit"
echo -e " -i, --interactive Prompt for confirmation before each step (overrides --skip-confirm)\n"
echo -e "\nExamples:\n"
echo -e " >_ ./scripts/publish-nightly.fish --dry-run"
echo -e " Output the steps that would be taken without executing them\n"
echo -e " >_ ./scripts/publish-nightly.fish --bump-pre && ./scripts/publish-nightly.fish"
echo -e " Bump the preminor version and then publish it\n"
echo -e " >_ ./scripts/publish-nightly.fish --skip-confirm"
echo -e " Skip all confirmation prompts and publish the next release\n"
exit 0
end
# Bump preminor flag: `--bump-pre`
if set -q _flag_bump_pre
# Get the current preminor version from npm, increment it, and format the new version string
set latest_version (npm show "fish-lsp@preminor" version 2>/dev/null)
set next_version (get_next_npm_preminor_version)
# Execute the version bump command
exec_cmd "Bump preminor version `$latest_version` → `$next_version`" "npm pkg set version=$next_version" --interactive
and log_info '✅' '[SUCCESS]' "Bumped preminor version to `$next_version`"
or fail "Failed to bump preminor version"
end
# Completion flag: `-c` or `--complete`
if set -q _flag_complete
set -l script (path resolve -- (status current-filename))
echo "# COMPLETIONS FROM `$script -c`
complete --path $script -f
complete --path $script -s h -l help -d 'Show this help message'
complete --path $script -s d -l dry-run -d 'Show what would happen without executing'
complete --path $script -s c -l complete -d 'Show completion commands for this script'
complete --path $script -l skip-confirm -d 'Don\'t prompt for confirmation'
complete --path $script -l bump-pre -d 'Bump the preminor version and exit'
complete --path $script -s i -l interactive -d 'Prompt for confirmation before each step (overrides --skip-confirm)'
# yarn publish-nightly
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -f
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -s h -l help -d 'Show this help message'
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -s d -l dry-run -d 'Show what would happen without executing'
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -s c -l complete -d 'Show completion commands for this script'
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -l skip-confirm -d 'Don\'t prompt for confirmation'
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -l bump-pre -d 'Bump the preminor version and exit'
complete -c yarn -n '__fish_seen_subcommand_from publish-nightly' -s i -l interactive -d 'Prompt for confirmation before each step (overrides --skip-confirm)'
" | string trim -l
exit 0
end
# ┌────────────────┐
# │ main execution │
# └────────────────┘
log_info '' '[INFO]' "Starting$BOLD_BLUE nightly+preminor$CYAN publish..."
# ┌──────────────────────┐
# │ setup info variables │
# └──────────────────────┘
set package_name (get_npm_pkg_name)
set package_version (get_npm_pkg_version)
test -z "$package_name" -o -z "$package_version"; and fail "Cannot read package.json"
log_info '📦' '[INFO]' "Package: $BLUE$package_name@$package_version$NORMAL"
set git_tag "v$package_version"
set npm_url (get_npm_url)
# ┌─────────────────────┐
# │ check tag conflicts │
# └─────────────────────┘
check_and_fix_tag; or fail "Pre-publish checks failed"
# ┌───────────────┐
# │ Confirm BEGIN │
# └───────────────┘
log_info '📋' '[PLAN]' "Package: $BLUE$package_name@$package_version$NORMAL →$GREEN npm:preminor,nightly$NORMAL +$BRIGHT_GREEN git:$git_tag$NORMAL"
confirm "Proceed with publish"; or fail "Aborted by user"
# ┌───────────────────────┐
# │ Execute publish steps │
# └───────────────────────┘
# npm
exec_cmd "Publish to npm" "npm publish --tag preminor" --interactive --numbered; or fail "npm publish failed"
exec_cmd "Add nightly tag" "npm dist-tag add $package_name@$package_version nightly" --interactive --numbered; or fail "dist-tag failed"
# git
exec_cmd "Create git tag" "git tag -a $git_tag -m 'Published to npm: $npm_url'" --interactive --numbered; or fail "git tag failed"
exec_cmd "Push git tag" "git push origin $git_tag" --interactive --numbered; or fail "git push failed"
# ┌───────────────────────┐
# │ Final success message │
# └───────────────────────┘
not $DRY_RUN; and log_info '✅' '[SUCCESS]' "Published $BLUE$package_name@$package_version$NORMAL"
$DRY_RUN; and log_info '' '[DRY RUN]' "Would have published: $BLUE$package_name@$package_version$NORMAL"
or log_info '' '[DONE]' 'Finished successfully'
================================================
FILE: scripts/relink-locally.fish
================================================
#!/usr/bin/env fish
# Relink a globally installed package to the local package
# that was called by this script. If the global package
# is not installed, it will be installed globally.
# Use this for testing changes to the fish-lsp package.
# Usage: ./relink-locally.sh
source ./scripts/fish/pretty-print.fish
argparse --max-args 1 h/help q/quiet v/verbose no-stderr -- $argv
or return
if set -q _flag_help
echo 'NAME:'
echo ' relink-locally.fish'
echo ''
echo 'DESCRIPTION:'
echo ' Handle relinking pkg. Default usage silences any subshell relinking output.'
echo ' Option \'-q,--quiet\' (silence all subsubshell output), is assumed for'
echo ' usage without an option.'
echo ''
echo 'OPTIONS:'
echo -e ' -q,--quiet\tsilence all [DEFAULT]'
echo -e ' -v,--verbose\tno silencing subshells'
echo -e ' --no-stderr\tsilence stderr in subshells'
echo -e ' -h,--help\tshow this message'
return 0
end
if set -q _flag_no_stderr
# show all sub shells w/ only stdout
if command -vq fish-lsp
echo ' "fish-lsp" is already installed'
echo ' UNLINKING and LINKING again'
yarn unlink --global fish-lsp 2>/dev/null
yarn global remove fish-lsp 2>/dev/null
end
yarn link --global fish-lsp --force 2>/dev/null
echo 'SUCCESS! "fish-lsp" is now installed and linked'
return 0
else if set -q _flag_verbose
# show all sub shells w/ stdout stderr
if command -vq fish-lsp
echo ' "fish-lsp" is already installed'
echo ' UNLINKING and LINKING again'
yarn unlink --global fish-lsp
or return 1
yarn global remove fish-lsp
or return 1
end
yarn link --global fish-lsp --force
and echo 'SUCCESS! "fish-lsp" is now installed and linked'
return $status
else
# silence all sub shells (don't include stdout & stderr)
# occurs when: ZERO flags given or $_flag_quiet
echo $YELLOW" RELINKING $BLUE\"fish-lsp\"$YELLOW GLOBALLY..."$NORMAL
if command -vq fish-lsp
echo $YELLOW" $(icon_warning) command $BLUE\"fish-lsp\"$YELLOW is already installed"$NORMAL
echo $YELLOW" $(icon_file) UNLINKING and LINKING again"$NORMAL
yarn unlink --global fish-lsp &>/dev/null
yarn global remove fish-lsp &>/dev/null
end
yarn link --global fish-lsp --force &>/dev/null
# echo -e $BOLD_GREEN"$(icon_check)SUCCESS! $BLUE\"fish-lsp\"$BOLD_GREEN is now installed and linked"$NORMAL
print_success "$BLUE\"fish-lsp\"$GREEN is now installed and linked globally"
return 0
end
================================================
FILE: scripts/update-changelog.fish
================================================
#!/usr/bin/env fish
source ./scripts/fish/pretty-print.fish
source ./scripts/fish/continue-or-exit.fish
log_info ' ' '[RUN]' 'Update `docs/CHANGELOG.md` SCRIPT'
print_separator
log_info 'ℹ️' '[INFO]' 'Dry run of how the `docs/CHANGELOG.md` will be updated...'
print_separator
yarn -s util:update-changelog:dry:diff 2>/dev/null
print_separator
# continue_or_exit --quiet --prepend-prompt='This will update the `./docs/CHANGELOG.md`. Do you want to continue?' --prompt-str='(y/n)?'
if not continue_or_exit --time-in-prompt --quiet --prepend-prompt='This will update the `./docs/CHANGELOG.md`. Do you want to continue?' --prompt-str="$GREEN$BOLD$UNDERLINE$REVERSE(y/n)?$NORMAL"' ' || false
log_warning '⚠️' '[WARNING]' 'SKIPPING `docs/CHANGELOG.md` UPDATE'
else
yarn util:update-changelog
log_info '✅' '[INFO]' 'UPDATED `docs/CHANGELOG.md`'
end
print_separator
================================================
FILE: scripts/update-codeblocks-in-docs.ts
================================================
#!/usr/bin/env tsx
/**
* Script to update markdown code blocks based on special HTML comments
*
* This script searches for HTML comments in the format:
*
* ```language
* old content
* ```
*
* For each comment found, it:
* 1. Extracts the command after the colon
* 2. Executes the command in fish shell
* 3. Replaces only the code block content (preserving the ```language markers)
*
* Usage:
* tsx scripts/update-codeblocks-in-docs.ts [--dry-run] [path]
*
* Options:
* --dry-run Show what would be changed without modifying files
* path Absolute path to a file or directory to process (optional)
* If not provided, searches all markdown files in the workspace
*/
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import fg from 'fast-glob';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const WORKSPACE_ROOT = resolve(__dirname, '..');
const DRY_RUN = process.argv.includes('--dry-run');
// Get custom path from args (filter out script name, node, tsx, and flags)
const args = process.argv.slice(2).filter(arg => !arg.includes('--') && !arg.includes('node_modules') && !arg.endsWith('.ts'));
const customPath = args.length > 0 ? resolve(args[0]) : undefined;
interface UpdateDirective {
lineNumber: number;
command: string;
}
function extractUpdateDirectives(content: string): UpdateDirective[] {
const lines = content.split('\n');
const directives: UpdateDirective[] = [];
const pattern = //;
lines.forEach((line, index) => {
const match = line.match(pattern);
if (match && match[1]) {
directives.push({
lineNumber: index,
command: match[1].trim(),
});
}
});
return directives;
}
function executeCommand(command: string): { output: string; status: number } {
try {
const output = execSync(command, {
encoding: 'utf-8',
shell: '/usr/bin/fish',
stdio: ['pipe', 'pipe', 'pipe'],
});
return { output: output.trimEnd(), status: 0 };
} catch (error: any) {
return {
output: error.stdout?.toString() || error.stderr?.toString() || '',
status: error.status || 1,
};
}
}
function processReadme(content: string, filePath: string): { newContent: string; updatesCount: number } {
const lines = content.split('\n');
const directives = extractUpdateDirectives(content);
if (directives.length === 0) {
return { newContent: content, updatesCount: 0 };
}
let updatesCount = 0;
const output: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Check if current line has an update directive
const directive = directives.find(d => d.lineNumber === i);
if (directive) {
console.error(` ✓ Line ${i + 1}: ${directive.command}`);
// Add the comment line
output.push(line);
i++;
// Find the opening backticks
while (i < lines.length) {
const currentLine = lines[i];
if (currentLine.match(/^```/)) {
// Found opening backticks - preserve them
const codeblockOpening = currentLine;
output.push(codeblockOpening);
i++;
// Execute command
console.error(` Executing: ${directive.command}`);
const { output: commandOutput, status } = executeCommand(directive.command);
if (status !== 0) {
console.error(` ❌ Error: Command exited with status ${status} - skipping update`);
// Command failed - keep old content
while (i < lines.length) {
const contentLine = lines[i];
output.push(contentLine);
if (contentLine.match(/^```/)) {
// Found closing backticks
i++;
break;
}
i++;
}
break;
}
// Add new content (command succeeded)
output.push(commandOutput);
// Skip old content until closing backticks
while (i < lines.length) {
const contentLine = lines[i];
if (contentLine.match(/^```/)) {
// Found closing backticks
output.push(contentLine);
i++;
break;
}
// Skip old content line
i++;
}
updatesCount++;
break;
}
// Line between comment and codeblock
output.push(currentLine);
i++;
}
} else {
// Regular line
output.push(line);
i++;
}
}
return { newContent: output.join('\n'), updatesCount };
}
function getMarkdownFiles(targetPath?: string): string[] {
if (targetPath) {
// Check if path exists
if (!existsSync(targetPath)) {
console.error(`Error: Path does not exist: ${targetPath}`);
process.exit(1);
}
const stats = statSync(targetPath);
if (stats.isFile()) {
// Single file - verify it's a markdown file
if (!targetPath.endsWith('.md')) {
console.error(`Error: File is not a markdown file: ${targetPath}`);
process.exit(1);
}
return [targetPath];
} else if (stats.isDirectory()) {
// Directory - find all markdown files
return fg.sync('**/*.md', {
cwd: targetPath,
absolute: true,
ignore: ['**/node_modules/**', '**/.git/**'],
});
}
}
// No path provided - search workspace
return fg.sync('**/*.md', {
cwd: WORKSPACE_ROOT,
absolute: true,
ignore: ['**/node_modules/**', '**/.git/**'],
});
}
function processFile(filePath: string): { updated: boolean; updatesCount: number } {
const content = readFileSync(filePath, 'utf-8');
const directives = extractUpdateDirectives(content);
if (directives.length === 0) {
return { updated: false, updatesCount: 0 };
}
console.log(`\n📄 Processing: ${filePath}`);
console.log(` Found ${directives.length} directive(s)\n`);
const { newContent, updatesCount } = processReadme(content, filePath);
if (!DRY_RUN && updatesCount > 0) {
writeFileSync(filePath, newContent, 'utf-8');
}
return { updated: updatesCount > 0, updatesCount };
}
function main() {
if (DRY_RUN) {
console.log('🔍 DRY RUN MODE - No files will be modified\n');
}
console.log('Scanning for markdown files with FISH_LSP_UPDATE_CODEBLOCK directives...\n');
// Get markdown files to process
const markdownFiles = getMarkdownFiles(customPath);
if (markdownFiles.length === 0) {
console.log('No markdown files found.');
process.exit(0);
}
console.log(`Found ${markdownFiles.length} markdown file(s) to scan\n`);
// Process each file
let totalDirectives = 0;
let totalUpdates = 0;
let filesUpdated = 0;
for (const filePath of markdownFiles) {
const { updated, updatesCount } = processFile(filePath);
if (updated) {
filesUpdated++;
totalUpdates += updatesCount;
}
// Count directives in file
const content = readFileSync(filePath, 'utf-8');
const directives = extractUpdateDirectives(content);
totalDirectives += directives.length;
}
// Summary
console.log('\n' + '='.repeat(60));
if (DRY_RUN) {
console.log('🔍 DRY RUN COMPLETE - No changes were made');
console.log(` Scanned ${markdownFiles.length} file(s)`);
console.log(` Found ${totalDirectives} directive(s) in ${filesUpdated} file(s)`);
console.log(` Would update ${totalUpdates} codeblock(s)`);
} else {
console.log('✨ Processing complete!');
console.log(` Scanned ${markdownFiles.length} file(s)`);
console.log(` Found ${totalDirectives} directive(s) in ${filesUpdated} file(s)`);
console.log(` Updated ${totalUpdates} codeblock(s)`);
}
}
main();
================================================
FILE: scripts/workspace-cli.ts
================================================
#!/usr/bin/env tsx
import { Command } from 'commander';
import * as fs from 'fs';
import * as path from 'path';
import * as child_process from 'child_process';
import fastGlob from 'fast-glob';
import chalk from 'chalk';
// Minimal types for CLI usage (no LSP dependencies)
interface TestFileSpec {
relativePath: string;
content: string | string[];
}
interface WorkspaceSnapshot {
name: string;
files: TestFileSpec[];
timestamp: number;
}
class WorkspaceCLI {
static fromSnapshot(snapshotPath: string): { name: string; files: TestFileSpec[] } {
if (!fs.existsSync(snapshotPath)) {
throw new Error(`Snapshot file not found: ${snapshotPath}`);
}
const snapshotContent = fs.readFileSync(snapshotPath, 'utf8');
const snapshot: WorkspaceSnapshot = JSON.parse(snapshotContent);
return { name: snapshot.name, files: snapshot.files };
}
static convertSnapshotToWorkspace(snapshotPath: string, outputDir?: string): string {
const snapshot = this.fromSnapshot(snapshotPath);
const workspacePath = outputDir || path.join('tests/workspaces', snapshot.name);
// Create workspace directory
fs.mkdirSync(workspacePath, { recursive: true });
// Create fish directory structure
const fishDirs = new Set();
snapshot.files.forEach(file => {
const dir = path.dirname(file.relativePath);
if (dir !== '.') fishDirs.add(dir);
});
fishDirs.forEach(dir => {
const dirPath = path.join(workspacePath, dir);
fs.mkdirSync(dirPath, { recursive: true });
});
// Write files
snapshot.files.forEach(file => {
const filePath = path.join(workspacePath, file.relativePath);
const content = Array.isArray(file.content) ? file.content.join('\n') : file.content;
fs.writeFileSync(filePath, content, 'utf8');
});
return workspacePath;
}
static readWorkspace(folderPath: string): { path: string; files: string[] } {
const absPath = path.isAbsolute(folderPath)
? folderPath
: fs.existsSync(path.join('tests/workspaces', folderPath))
? path.resolve(path.join('tests/workspaces', folderPath))
: path.resolve(folderPath);
if (!fs.existsSync(absPath)) {
throw new Error(`Workspace directory not found: ${absPath}`);
}
// Check if there's a fish subdirectory
let searchPath = absPath;
if (fs.existsSync(path.join(absPath, 'fish')) && fs.statSync(path.join(absPath, 'fish')).isDirectory()) {
searchPath = path.join(absPath, 'fish');
}
const files = fastGlob.sync(['**/*.fish'], { cwd: searchPath });
return { path: absPath, files };
}
static convertWorkspaceToSnapshot(folderPath: string, outputPath?: string): string {
const workspace = this.readWorkspace(folderPath);
// Check if there's a fish subdirectory
let searchPath = workspace.path;
if (fs.existsSync(path.join(workspace.path, 'fish'))) {
searchPath = path.join(workspace.path, 'fish');
}
const files: TestFileSpec[] = [];
workspace.files.forEach(relPath => {
const fullPath = path.join(searchPath, relPath);
const content = fs.readFileSync(fullPath, 'utf8');
files.push({ relativePath: relPath, content });
});
const snapshot: WorkspaceSnapshot = {
name: path.basename(workspace.path),
files,
timestamp: Date.now()
};
const snapshotPath = outputPath || path.join(path.dirname(workspace.path), `${snapshot.name}.snapshot`);
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
return snapshotPath;
}
static showFileTree(dirPath: string): string {
if (!fs.existsSync(dirPath)) {
return 'Directory not found';
}
const tree: string[] = [];
const buildTree = (dir: string, prefix = '') => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
entries.forEach((entry, index) => {
const isLast = index === entries.length - 1;
const currentPrefix = prefix + (isLast ? '└── ' : '├── ');
tree.push(currentPrefix + entry.name);
if (entry.isDirectory()) {
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
buildTree(path.join(dir, entry.name), nextPrefix);
}
});
};
tree.push(path.basename(dirPath) + '/');
buildTree(dirPath, '');
return tree.join('\n');
}
static async showTreeSitterAST(folderPath: string, useColors: boolean = true): Promise {
const workspace = this.readWorkspace(folderPath);
let searchPath = workspace.path;
if (fs.existsSync(path.join(workspace.path, 'fish'))) {
searchPath = path.join(workspace.path, 'fish');
}
for (let idx = 0; idx < workspace.files.length; idx++) {
const relPath = workspace.files[idx];
const fullPath = path.join(searchPath, relPath);
try {
// Use child_process to call fish-lsp info --dump-parse-tree
const colorFlag = useColors ? '' : '--no-color';
const cmd = `fish-lsp info --dump-parse-tree ${colorFlag} "${fullPath}"`;
const result = child_process.execSync(cmd, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
});
if (idx > 0) console.log(chalk.white('---------------------------------------------'));
console.log('file:', chalk.green(`${relPath}`));
console.log();
console.log(result);
if (idx < workspace.files.length - 1) {
console.log();
}
} catch (error) {
console.error(`❌ Error parsing ${relPath}:`, error.message);
if (error.stderr) {
console.error(`stderr: ${error.stderr}`);
}
}
}
}
}
// Generate fish shell completions for yarn sh:workspace-cli
function generateFishCompletions(): void {
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -f`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -s h -l help -d "Show help"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -s V -l version -d "Show version"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -s c -l completions -d "Generate fish completions"`);
// read command
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "not __fish_seen_subcommand_from read snapshot-to-workspace workspace-to-snapshot show help" -a "read" -d "Read and display workspace from directory"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from read" -l show-tree -d "Show file tree"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from read" -F`);
// snapshot-to-workspace command
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "not __fish_seen_subcommand_from read snapshot-to-workspace workspace-to-snapshot show help" -a "snapshot-to-workspace" -d "Convert snapshot file to workspace directory"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from snapshot-to-workspace" -s o -l output -d "Output directory" -F`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from snapshot-to-workspace" -k -xa "(find . -name '*.snapshot' -type f 2>/dev/null)"`);
// workspace-to-snapshot command
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "not __fish_seen_subcommand_from read snapshot-to-workspace workspace-to-snapshot show help" -a "workspace-to-snapshot" -d "Convert workspace directory to snapshot file"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from workspace-to-snapshot" -s o -l output -d "Output snapshot file path" -F`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from workspace-to-snapshot" -F`);
// show command
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "not __fish_seen_subcommand_from read snapshot-to-workspace workspace-to-snapshot show help" -a "show" -d "Display snapshot or workspace contents"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from show" -l show-tree -d "Show file tree (for workspaces)"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from show" -l show-tree-sitter-ast -d "Show Tree-sitter AST for each fish file (for workspaces)"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from show" -l no-color -d "Disable color output for Tree-sitter AST"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from show" -F`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from show" -k -xa "(find . -name '*.snapshot' -type f 2>/dev/null)"`);
// help command
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "not __fish_seen_subcommand_from read snapshot-to-workspace workspace-to-snapshot show help" -a "help" -d "Display help for command"`);
console.log(`complete -c yarn -n "__fish_seen_subcommand_from sh:workspace-cli" -n "__fish_seen_subcommand_from help" -xa "read snapshot-to-workspace workspace-to-snapshot show"`);
}
// CLI setup
const program = new Command()
.name('workspace-cli')
.description('Test workspace utilities - convert between snapshots and folders')
.version('1.0.0')
.option('-c, --completions', 'Generate fish shell completions');
program
.command('read')
.description('Read and display workspace from directory')
.argument('', 'Path to workspace directory')
.option('--show-tree', 'Show file tree')
.action((workspacePath, options) => {
try {
const workspace = WorkspaceCLI.readWorkspace(workspacePath);
console.log(`📁 Workspace: ${workspace.path}`);
console.log(`📄 Found ${workspace.files.length} fish files`);
workspace.files.forEach(file => {
console.log(` ${file}`);
});
if (options.showTree) {
console.log('\n🌳 File tree:');
console.log(WorkspaceCLI.showFileTree(workspace.path));
}
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
});
program
.command('snapshot-to-workspace')
.description('Convert snapshot file to workspace directory')
.argument('', 'Path to snapshot file')
.option('-o, --output ', 'Output directory')
.action((snapshotPath, options) => {
try {
const workspacePath = WorkspaceCLI.convertSnapshotToWorkspace(snapshotPath, options.output);
console.log(`✅ Converted snapshot to workspace:`);
console.log(` 📁 ${workspacePath}`);
const workspace = WorkspaceCLI.readWorkspace(workspacePath);
console.log(` 📄 ${workspace.files.length} files created`);
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
});
program
.command('workspace-to-snapshot')
.description('Convert workspace directory to snapshot file')
.argument('', 'Path to workspace directory')
.option('-o, --output ', 'Output snapshot file path')
.action((workspacePath, options) => {
try {
const snapshotPath = WorkspaceCLI.convertWorkspaceToSnapshot(workspacePath, options.output);
console.log(`✅ Converted workspace to snapshot:`);
console.log(` 📄 ${snapshotPath}`);
const snapshot = WorkspaceCLI.fromSnapshot(snapshotPath);
console.log(` 📁 ${snapshot.files.length} files archived`);
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
});
program
.command('show')
.description('Display snapshot or workspace contents')
.argument('', 'Path to snapshot file or workspace directory')
.option('--show-tree', 'Show file tree (for workspaces)')
.option('--show-tree-sitter-ast', 'Show Tree-sitter AST for each fish file (for workspaces)')
.option('--no-color', 'Disable color output for Tree-sitter AST')
.action(async (inputPath, options) => {
try {
if (inputPath.endsWith('.snapshot')) {
const snapshot = WorkspaceCLI.fromSnapshot(inputPath);
console.log(`📷 Snapshot: ${snapshot.name}`);
console.log(`📄 Files: ${snapshot.files.length}`);
snapshot.files.forEach(file => {
console.log(` ${file.relativePath}`);
});
} else {
const workspace = WorkspaceCLI.readWorkspace(inputPath);
console.log(`📁 Workspace: ${workspace.path}`);
console.log(`📄 Files: ${workspace.files.length}`);
workspace.files.forEach(file => {
console.log(` ${file}`);
});
if (options.showTree) {
console.log('\n🌳 File tree:');
console.log(WorkspaceCLI.showFileTree(workspace.path));
}
if (options.showTreeSitterAst) {
console.log('\n🌳 Tree-sitter AST:');
const useColors = !options.noColor;
await WorkspaceCLI.showTreeSitterAST(inputPath, useColors);
}
}
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
});
// Handle help and completions like the build script
if (process.argv.includes('--help') || process.argv.includes('-h')) {
program.outputHelp();
process.stdout.write(`\nExamples:\n`);
process.stdout.write(` $ yarn sh:workspace-cli show tests/workspaces/workspace_1 --show-tree-sitter-ast\n`);
process.stdout.write(` shows each tree-sitter tree for file in workspaces/workspace_1\n\n`);
process.stdout.write(` $ yarn sh:workspace-cli snapshot-to-workspace tests/workspaces/snapshot_comprehensive_test.snapshot \n`);
process.stdout.write(` convert snapshot workspace to actual file workspace\n\n`);
process.stdout.write(` $ yarn sh:workspace-cli show tests/workspaces/workspace_1 --show-tree \n`);
process.stdout.write(` shows file tree for workspace\n\n`);
process.exit(0);
}
if (process.argv.includes('--completions') || process.argv.includes('-c')) {
generateFishCompletions();
process.exit(0);
}
program.parse();
================================================
FILE: src/analyze.ts
================================================
import * as LSP from 'vscode-languageserver';
import { DocumentUri, Hover, Location, Position, SymbolKind, URI, WorkDoneProgressReporter, WorkspaceSymbol } from 'vscode-languageserver';
import * as Parser from 'web-tree-sitter';
import { SyntaxNode, Tree } from 'web-tree-sitter';
import { dirname } from 'path';
import { config } from './config';
import { documents, LspDocument } from './document';
import { logger } from './logger';
import { isArgparseVariableDefinitionName } from './parsing/argparse';
import { CompletionSymbol, isCompletionCommandDefinition, isCompletionSymbol, processCompletion } from './parsing/complete';
import { createSourceResources, getExpandedSourcedFilenameNode, isSourceCommandArgumentName, isSourceCommandWithArgument, symbolsFromResource } from './parsing/source';
import { filterFirstPerScopeSymbol, FishSymbol, processNestedTree } from './parsing/symbol';
import { getImplementation } from './references';
import { execCommandLocations } from './utils/exec';
import { SyncFileHelper } from './utils/file-operations';
import { flattenNested, iterateNested } from './utils/flatten';
import { findParentCommand, findParentFunction, isAliasDefinitionName, isCommand, isCommandName, isOption, isTopLevelDefinition, isExportVariableDefinitionName } from './utils/node-types';
import { pathToUri, symbolKindToString, uriToPath } from './utils/translation';
import { containsRange, getChildNodes, getNamedChildNodes, getRange, isPositionAfter, isPositionWithinRange, namedNodesGen, nodesGen, precedesRange } from './utils/tree-sitter';
import { Workspace } from './utils/workspace';
import { workspaceManager } from './utils/workspace-manager';
import { initializeParser } from './parser';
import { BufferedAsyncDiagnosticCache } from './diagnostics/buffered-async-cache';
import { env } from 'src/utils/env-manager';
/*************************************************************/
/* ts-doc type imports for links to other files here */
/*************************************************************/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { FishServer } from './server'; // @ts-ignore
/*************************************************************/
/**
* Type of AnalyzedDocument, either 'partial' or 'full'.
* - 'partial' documents do not have all properties computed,
* - 'full' documents have all properties computed.
*
* @see {@link AnalyzedDocument#isPartial()} check if the document is partially parsed.
* @see {@link AnalyzedDocument#isFull()} check if the document is fully parsed.
*
* @see {@link AnalyzedDocument#ensureParsed()} convert any partial documents to full ones and update {@link analyzer.cache}.
*/
export type AnalyzedDocumentType = 'partial' | 'full';
/**
* Specialized type of AnalyzedDocument that guarantees all the properties
* are present so that consumers can avoid null checks once they have already
* ensured the document is fully analyzed.
*
* This type will be returned from the `AnalyzedDocument.ensureParsed()` method,
* which makes sure any partial documents are fully computed and updated.
* @see {@link AnalyzedDocument#ensureParsed()}
*/
export type EnsuredAnalyzeDocument = Required & { root: SyntaxNode; tree: Tree; type: 'full'; };
/**
* AnalyzedDocument items are created in three public methods of the Analyzer class:
* - analyze()
* - analyzePath()
* - analyzePartial()
*
* A partial AnalyzeDocument will not have the documentSymbols computed, because we
* don't expect there to be global definitions in the document (based off of the
* uri. i.e., $__fish_config_dir/completions/*.fish). Partial AnalyzeDocuments are
* used to greatly reduce the overhead required for background indexing of large
* workspaces.
*
* Use the AnalyzeDocument namespace to create `AnalyzedDocument` items.
*/
export class AnalyzedDocument {
/**
* private constructor to enforce the use of static creation methods.
* @see {@link AnalyzedDocument.create()} for usage.
*
* @param document The LspDocument that was analyzed.
* @param documentSymbols A nested array of FishSymbols, representing the symbols in the document.
* @param flatSymbols A flat array of FishSymbols, representing all symbols in the document.
* @param tree A tree that has been parsed by web-tree-sitter
* @param root root node of a SyntaxTree
* @param commandNodes A flat array of every command used in this document
* @param sourceNodes All the `source some_file_path` nodes in a document, scoping is not considered.
* However, the nodes can be filtered to consider scoping at a later time.
* @param type If the document has been fully analyzed, or only partially.
*
* @returns An instance of AnalyzedDocument.
*/
private constructor(
/**
* The LspDocument that was analyzed.
*/
public document: LspDocument,
/**
* A nested array of FishSymbols, representing the symbols in the document.
*/
public documentSymbols: FishSymbol[] = [],
/**
* A flat array of FishSymbols, representing all symbols in the document.
*/
public flatSymbols: FishSymbol[] = [],
/**
* A tree that has been parsed by web-tree-sitter
*/
public tree?: Parser.Tree,
/**
* root node of a SyntaxTree
*/
public root?: Parser.SyntaxNode,
/**
* A flat array of every command used in this document
*/
public commandNodes: SyntaxNode[] = [],
/**
* All the `source some_file_path` nodes in a document, scoping is not considered.
* However, the nodes can be filtered to consider scoping at a later time.
*/
public sourceNodes: SyntaxNode[] = [],
/**
* If the document has been fully analyzed, or only partially.
*/
private type: AnalyzedDocumentType = tree ? 'full' : 'partial',
) {
if (tree) this.root = tree.rootNode || undefined;
}
/**
* Static method to create an AnalyzedDocument. If passed a tree, it will
* be considered a fully parsed document. Otherwise, it will be considered a partial document.
*
* @see {@link AnalyzedDocument.createFull()} {@link AnalyzedDocument.createPartial()}
*
* @param document The LspDocument that was analyzed.
* @param documentSymbols A nested array of FishSymbols, representing the symbols in the document.
* @param tree A tree that has been parsed by web-tree-sitter
* @param root root node of a SyntaxTree
* @param commandNodes A flat array of every command used in this document
* @param sourceNodes All the `source some_file_path` nodes in a document, scoping is not considered.
*
* @returns An instance of AnalyzedDocument returned from createdFull() or createdPartial().
*/
private static create(
document: LspDocument,
documentSymbols: FishSymbol[] = [],
flatSymbols: FishSymbol[] = [],
tree: Parser.Tree | undefined = undefined,
root: Parser.SyntaxNode | undefined = undefined,
commandNodes: SyntaxNode[] = [],
sourceNodes: SyntaxNode[] = [],
): AnalyzedDocument {
return new AnalyzedDocument(
document,
documentSymbols,
flatSymbols,
tree,
root || tree?.rootNode,
commandNodes,
sourceNodes,
tree ? 'full' : 'partial',
);
}
/**
* Static method to create a fully parsed AnalyzedDocument.
* Extracts both the commandNodes and sourceNodes from the tree provided.
*
* @see {@link AnalyzedDocument.create()} which handles initialization internally.
*
* @param document The LspDocument that was analyzed.
* @param documentSymbols A nested array of FishSymbols, representing the symbols in the document.
* @param tree A tree that has been parsed by web-tree-sitter
*
* @returns An instance of AnalyzedDocument, with all properties populated.
*/
public static createFull(
document: LspDocument,
documentSymbols: FishSymbol[],
tree: Parser.Tree,
): AnalyzedDocument {
const commandNodes: SyntaxNode[] = [];
const sourceNodes: SyntaxNode[] = [];
tree.rootNode.descendantsOfType('command').forEach(node => {
if (isSourceCommandWithArgument(node)) sourceNodes.push(node.child(1)!);
commandNodes.push(node);
});
return new AnalyzedDocument(
document,
documentSymbols,
flattenNested(...documentSymbols),
tree,
tree.rootNode,
commandNodes,
sourceNodes,
'full',
);
}
/**
* Static method to create a partially parsed AnalyzedDocument. Partial documents
* do not compute any expensive properties such as documentSymbols, commandNodes, or sourceNodes.
*
* This saves significant time during initial workspace analysis, especially for large workspaces
* by assuming certain documents (such as those in completions directories) do not contain
* global `FishSymbol[]` definitions. We can then lazily compute partial documents
* by checking if opened/changed documents had references to lazily loaded documents.
*
* @see {@link AnalyzedDocument.create()} which handles initialization internally.
* @see {@link AnalyzedDocument#ensureParsed()} to fully parse a partial document when needed.
*
* @param document The LspDocument that was analyzed.
*
* @returns An instance of AnalyzedDocument, with only the document property populated.
*/
public static createPartial(document: LspDocument): AnalyzedDocument {
return AnalyzedDocument.create(document);
}
/**
* Check if the AnalyzedDocument is partial (not fully parsed).
* @see {@link AnalyzedDocument#ensureParsed()} which will convert a partial document to a full one.
* @returns {boolean} True if the AnalyzedDocument is partial, false otherwise.
*/
public isPartial(): boolean {
return this.type === 'partial';
}
/**
* Check if the AnalyzedDocument is fully parsed.
* @returns {boolean} True if the AnalyzedDocument is full, false otherwise.
*/
public isFull(): boolean {
return this.type === 'full';
}
public ensureParsed(): EnsuredAnalyzeDocument {
if (this.isPartial()) {
const fullDocument = analyzer.analyze(this.document);
// Update this instance's properties in-place
this.documentSymbols = fullDocument.documentSymbols;
this.flatSymbols = fullDocument.flatSymbols;
this.tree = fullDocument.tree;
this.root = fullDocument.root;
this.commandNodes = fullDocument.commandNodes;
this.sourceNodes = fullDocument.sourceNodes;
this.type = 'full';
// Update the cache with the fully parsed document
analyzer.cache.setDocument(this.document.uri, this);
return this as EnsuredAnalyzeDocument;
}
return this as EnsuredAnalyzeDocument;
}
}
/**
* Call `await analyzer.initialize()` to create an instance of the Analyzer class.
* This way we avoid instantiating the parser, and passing it to each analyzer
* instance that we create (common test pattern). Also, by initializing the
* analyzer globally, we can import it to any procedure that needs access
* to the analyzer.
*
* The analyzer stores and computes our symbols, from the tree-sitter AST and
* caches the results in AnalyzedDocument[] items.
*/
export let analyzer: Analyzer;
/***
* Handles analysis of documents and caching their symbols.
*
* Lots of server functionality is implemented here. Including, but not limited to:
* - tree sitter parsing
* - document analysis and caching
* - workspace/document symbol searching
* - background analysis performed on startup
*
* Requires a tree-sitter Parser instance to be initialized for usage.
*/
export class Analyzer {
/**
* The cached documents from all workspaces
* - keys are the document uris
* - values are the AnalyzedDocument objects
*/
public cache: AnalyzedDocumentCache = new AnalyzedDocumentCache();
/**
* All of the global symbols throughout all workspaces in the server.
* Methods that use this cache might try to limit symbols to a single workspace.
*
* The `globalSymbols.map` is a used to cache the symbols for quick access
* - keys are the symbol names
* - values are the FishSymbol objects
*/
public globalSymbols: GlobalDefinitionCache = new GlobalDefinitionCache();
public started = false;
public diagnostics: BufferedAsyncDiagnosticCache = new BufferedAsyncDiagnosticCache();
constructor(public parser: Parser) { }
/**
* The method that is used to instantiate the **singleton** {@link analyzer}, to avoid
* dependency injecting the analyzer in every utility that might need it.
*
* This method can be called during the `connection.onInitialize()` in the server,
* or {@link https://vitest.dev/ | vite.beforeAll()} in a test-suite.
*
* @example
* ```typescript
* // file: ./tests/some-test-file.test.ts
* import { Analyzer, analyzer } from '../src/analyze';
*
* // Initialize the `analyzer` singleton through the `Analyzer.initialize()`
* // method to make it available throughout testing. This helps keep tests
* // consistent with the analysis functionality used throughout entire server.
*
* describe('test suite', () => {
* // Make sure the analyzer is initialized before any tests run
* beforeAll(async () => {
* await Analyzer.initialize();
* // analyzer.parser exists if needed
* // we can also use analyzer anywhere now in the test file
* });
* it('test 1', () => {
* const result1 = analyzer.analyzePath('/path/to/file.fish');
* const result2 = analyzer.analyze(result1.document);
* expect(result1.document.uri).toBe(result2.document.uri);
* });
* it('test 2', () => {
* const tree = analyzer.parser.parse('fish --help')
* const { rootNode } = tree;
* expect(rootNode).toBeDefined();
* });
* // ...
* });
* ```
*
* ___
*
* It is okay to use the {@link Analyzer} returned for testing purposes, however for
* consistency throughout source code, please use the exported {@link analyzer} variable.
*
* @returns Promise The initialized Analyzer instance (recommended to directly import {@link analyzer}).
*/
public static async initialize(): Promise {
const parser = await initializeParser();
analyzer = new Analyzer(parser);
analyzer.started = true;
return analyzer;
}
/**
* Perform full analysis on a LspDocument to build a AnalyzedDocument containing
* useful information about the document. It will also add the information to both
* the cache of AnalyzedDocuments and the global symbols cache.
*
* @param document The {@link LspDocument} to analyze.
* @returns An {@linkcode AnalyzedDocument} object.
*/
public analyze(document: LspDocument): AnalyzedDocument {
const analyzedDocument = this.getAnalyzedDocument(document);
this.cache.setDocument(document.uri, analyzedDocument);
// Remove old global symbols for this document before adding new ones
this.globalSymbols.removeSymbolsByUri(document.uri);
// Add new global symbols
for (const symbol of iterateNested(...analyzedDocument.documentSymbols)) {
if (symbol.isGlobal()) this.globalSymbols.add(symbol);
}
return analyzedDocument;
}
/**
* Remove all global symbols for a document (used when document is closed or deleted)
*/
public removeDocumentSymbols(uri: string): void {
this.globalSymbols.removeSymbolsByUri(uri);
this.cache.clear(uri);
}
/**
* @param uri the DocumentUri of the document that needs resolution
* @returns AnalyzedDocument {@link @AnalyzedDocument} or undefined if the file could not be found.
*/
public analyzeUri(uri: DocumentUri): AnalyzedDocument | undefined {
const document = documents.get(uri) || SyncFileHelper.loadDocumentSync(uriToPath(uri));
if (!document) {
logger.warning(`analyzer.analyzePath: ${uri} not found`);
return undefined;
}
return this.analyze(document);
}
/**
* @summary
* Takes a path to a file and turns it into a LspDocument, to then be analyzed
* and cached. This is useful for testing purposes, or for the rare occasion that
* we need to analyze a file that is not yet a LspDocument.
*
* @param filepath The local machine's path to the document that needs resolution
* @returns AnalyzedDocument {@link @AnalyzedDocument} or undefined if the file could not be found.
*/
public analyzePath(rawFilePath: string): AnalyzedDocument | undefined {
const path = uriToPath(rawFilePath);
const document = SyncFileHelper.loadDocumentSync(path);
if (!document) {
logger.warning(`analyzer.analyzePath: ${path} not found`);
return undefined;
}
return this.analyze(document);
}
/**
* @public
* Use on documents where we can assume the document nodes aren't important.
* This could mainly be summarized as any file in `$fish_complete_path/*.fish`
* This greatly reduces the time it takes for huge workspaces to be analyzed,
* by only retrieving the bare minimum of information required from completion
* documents. Since completion documents are fully parsed, only once a request
* is made that requires a completion document, we are able to avoid building
* their document symbols here. Conversely, this means that if we were to use
* this method instead of the full `analyze()` method, any requests that need
* symbols from the document will not be able to retrieve them.
*
* @see {@link AnalyzedDocument#ensureParsed()} convert a partial document to a full one
* and update the {@link analyzer.cache} with the newly computed full document.
*
* @param document The {@link LspDocument} to analyze.
* @returns partial result of {@link AnalyzedDocument.createPartial()} with no computed
* properties set, which we use {@link FishServer#didChangeTextDocument()}
* to later ensure any reachable symbols are computed local to the open document.
*/
public analyzePartial(document: LspDocument): AnalyzedDocument {
const analyzedDocument = AnalyzedDocument.createPartial(document);
this.cache.setDocument(document.uri, analyzedDocument);
return analyzedDocument;
}
/**
* @private
*
* Helper method to get the AnalyzedDocument. Retrieves the parsed
* AST from {@link https://github.com/tree-sitter/tree-sitter/tree/master/lib/binding_web | web-tree-sitter's} {@link Parser},
*
* - processes the {@link DocumentSymbol},
* - stores the commands used in the document,
* - collects all the sourced command {@link SyntaxNode}'s arguments
* **(potential file paths)**
*
* @param LspDocument The {@link LspDocument} to analyze.
* @returns An {@link AnalyzedDocument} object.
*/
private getAnalyzedDocument(document: LspDocument): AnalyzedDocument {
const tree = this.parser.parse(document.getText());
const documentSymbols = processNestedTree(document, tree.rootNode);
return AnalyzedDocument.createFull(document, documentSymbols, tree);
}
/**
* Analyze a workspace and all its documents.
* Documents that are already analyzed will be skipped.
* For documents that are autoloaded completions, we only perform a partial analysis.
* This method also reports progress to the provided WorkDoneProgressReporter.
*
* @param workspace The workspace to analyze.
* @param progress Optional WorkDoneProgressReporter to report progress.
* @param callbackfn Optional callback function to report messages.
*/
public async analyzeWorkspace(
workspace: Workspace,
progress: WorkDoneProgressReporter | undefined = undefined,
callbackfn: (text: string) => void = (text: string) => logger.log(`analyzer.analyzerWorkspace(${workspace.name})`, text),
) {
const startTime = performance.now();
if (workspace.isAnalyzed()) {
callbackfn(`[fish-lsp] workspace ${workspace.name} already analyzed`);
progress?.done();
return { count: 0, workspace, duration: '0.00' };
}
// progress?.begin(workspace.name, 0, 'Analyzing workspace', true);
const docs = workspace.pendingDocuments();
const maxSize = Math.min(docs.length, config.fish_lsp_max_background_files);
const currentDocuments = workspace.pendingDocuments().slice(0, maxSize);
// Helper function to delay execution
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Calculate adaptive delay and batch size based on document count
const BATCH_SIZE = Math.max(1, Math.floor(currentDocuments.length / 20));
const UPDATE_DELAY = currentDocuments.length > 100 ? 10 : 25; // Shorter delay for large sets
let lastUpdateTime = 0;
const MIN_UPDATE_INTERVAL = 15; // Minimum ms between visual updates
currentDocuments.forEach(async (doc, idx) => {
try {
if (doc.getAutoloadType() === 'completions') {
this.analyzePartial(doc);
} else {
this.analyze(doc);
}
workspace.uris.markIndexed(doc.uri);
const reportPercent = Math.ceil(idx / maxSize * 100);
progress?.report(reportPercent, `Analyzing ${idx}/${docs.length} files`);
} catch (err) {
logger.log(`[fish-lsp] ERROR analyzing workspace '${workspace.name}' (${err?.toString() || ''})`);
}
const currentTime = performance.now();
const isLastItem = idx === currentDocuments.length - 1;
const isBatchEnd = idx % BATCH_SIZE === BATCH_SIZE - 1;
const timeToUpdate = currentTime - lastUpdateTime > MIN_UPDATE_INTERVAL;
if (isLastItem || isBatchEnd && timeToUpdate) {
const percentage = Math.ceil((idx + 1) / maxSize * 100);
progress?.report(`${percentage}% Analyzing ${idx + 1}/${maxSize} ${maxSize > 1 ? 'documents' : 'document'}`);
lastUpdateTime = currentTime;
// Add a small delay for visual perception
await delay(UPDATE_DELAY);
}
});
progress?.done();
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2); // Convert to seconds with 2 decimal places
const count = currentDocuments.length;
const message = `Analyzed ${count} document${count > 1 ? 's' : ''} in ${duration}s`;
callbackfn(message);
return {
count: currentDocuments.length,
workspace: workspace,
duration,
};
}
/**
* Return the first FishSymbol seen that matches is defined at the location passed in
*/
public getSymbolAtLocation(location: Location): FishSymbol | undefined {
const symbols = this.cache.getFlatDocumentSymbols(location.uri);
return symbols.find((symbol) => symbol.equalsLocation(location));
}
/**
* Return the first FishSymbol seen that could be defined by the given position.
*/
public findDocumentSymbol(
document: LspDocument,
position: Position,
): FishSymbol | undefined {
const symbols = flattenNested(...this.cache.getDocumentSymbols(document.uri));
return symbols.find((symbol) => {
return isPositionWithinRange(position, symbol.selectionRange);
});
}
/**
* Return all FishSymbols seen that could be defined by the given position.
*/
public findDocumentSymbols(
document: LspDocument,
position: Position,
): FishSymbol[] {
const symbols = flattenNested(...this.cache.getDocumentSymbols(document.uri));
return symbols.filter((symbol) => {
return isPositionWithinRange(position, symbol.selectionRange);
});
}
/**
* Search through all the documents in the cache, and return the first symbol found
* that matches the callback function.
*/
public findSymbol(
callbackfn: (symbol: FishSymbol, doc?: LspDocument) => boolean,
) {
for (const uri of this.getIterableUris()) {
const symbols = this.cache.getFlatDocumentSymbols(uri);
const document = this.cache.getDocument(uri)?.document;
const symbol = symbols.find(s => callbackfn(s, document));
if (symbol) {
return symbol;
}
}
return undefined;
}
/**
* Search through all the documents in the cache, and return all symbols found
*/
public findSymbols(
callbackfn: (symbol: FishSymbol, doc?: LspDocument) => boolean,
): FishSymbol[] {
const symbols: FishSymbol[] = [];
for (const uri of this.getIterableUris()) {
const document = this.cache.getDocument(uri)?.document;
const symbols = this.getFlatDocumentSymbols(document!.uri);
const newSymbols = symbols.filter(s => callbackfn(s, document));
if (newSymbols) {
symbols.push(...newSymbols);
}
}
return symbols;
}
/**
* Search through all the documents in the cache, and return the first node found
*/
public findNode(
callbackfn: (n: SyntaxNode, document?: LspDocument) => boolean,
): SyntaxNode | undefined {
const uris = this.cache.uris();
for (const uri of uris) {
const root = this.cache.getRootNode(uri);
const document = this.cache.getDocument(uri)!.document;
if (!root || !document) continue;
const node = getChildNodes(root).find((n) => callbackfn(n, document));
if (node) {
return node;
}
}
return undefined;
}
/**
* Search through all the documents in the cache, and return all nodes found (with their uris)
*/
public findNodes(
callbackfn: (node: SyntaxNode, document: LspDocument) => boolean,
// useCurrentWorkspace: boolean = true,
): {
uri: string;
nodes: SyntaxNode[];
}[] {
const result: { uri: string; nodes: SyntaxNode[]; }[] = [];
for (const uri of this.getIterableUris()) {
const root = this.cache.getRootNode(uri);
const document = this.cache.getDocument(uri)?.document;
if (!root || !document) continue;
const nodes = getChildNodes(root).filter((node) => callbackfn(node, document));
if (nodes.length > 0) {
result.push({ uri: document.uri, nodes });
}
}
return result;
}
/**
* A generator function that yields all the documents in the workspace.
*/
public * findDocumentsGen(): Generator {
for (const uri of this.getIterableUris()) {
const document = this.cache.getDocument(uri)?.document;
if (document) {
yield document;
}
}
}
/**
* A generator function that yields all the symbols in the workspace, per document
* The symbols yielded are flattened FishSymbols (NOT nested).
*/
public * findSymbolsGen(): Generator<{ document: LspDocument; symbols: FishSymbol[]; }> {
for (const uri of this.getIterableUris()) {
const symbols = this.cache.getFlatDocumentSymbols(uri);
const document = this.cache.getDocument(uri)?.document;
if (!document || !symbols) continue;
yield { document, symbols };
}
}
/**
* A generator function that yields all the nodes in the workspace, per document.
* The nodes yielded are using the `this.getNodes()` method, which returns the cached
* nodes for the document.
*/
public * findNodesGen(): Generator<{ document: LspDocument; nodes: Generator; }> {
for (const uri of this.getIterableUris()) {
const root = this.cache.getRootNode(uri);
const document = this.cache.getDocument(uri)?.document;
if (!root || !document) continue;
yield { document, nodes: this.nodesGen(document.uri).nodes };
}
}
/**
* Collect all the global symbols in the workspace, and the document symbols usable
* at the requests position. DocumentSymbols that are not in the position's scope are
* excluded from the result array of FishSymbols.
*
* This method is mostly notably used for providing the symbols in
* `server.onCompletion()` requests.
*
* @param document The LspDocument to search in
* @param position The position to search at
* @returns {FishSymbol[]} A flat array of FishSymbols that are usable at the given position
*/
public allSymbolsAccessibleAtPosition(document: LspDocument, position: Position): FishSymbol[] {
// Set to avoid duplicate symbols
const symbolNames: Set = new Set();
// add the local symbols
const symbols = flattenNested(...this.cache.getDocumentSymbols(document.uri))
.filter((symbol) => symbol.scope.containsPosition(position));
symbols.forEach((symbol) => symbolNames.add(symbol.name));
// add the sourced symbols
const sourcedUris = this.collectReachableSources(document.uri, position);
for (const sourcedUri of Array.from(sourcedUris)) {
const sourcedSymbols = this.cache.getFlatDocumentSymbols(sourcedUri)
.filter(s =>
!symbolNames.has(s.name)
&& (s.isGlobal() || s.isRootLevel())
&& s.uri !== document.uri,
);
symbols.push(...sourcedSymbols);
sourcedSymbols.forEach((symbol) => symbolNames.add(symbol.name));
}
// add the global symbols
for (const globalSymbol of this.globalSymbols.allSymbols) {
// skip any symbols that are already in the result so that
// next conditionals don't have to consider duplicate symbols
if (symbolNames.has(globalSymbol.name)) continue;
// any global symbol not in the document
if (globalSymbol.uri !== document.uri) {
symbols.push(globalSymbol);
symbolNames.add(globalSymbol.name);
// any symbol in the document that is globally scoped
} else if (globalSymbol.uri === document.uri) {
symbols.push(globalSymbol);
symbolNames.add(globalSymbol.name);
}
}
return symbols;
}
/**
* method that returns all the workspaceSymbols that are in the same scope as the given
* shell
* @returns {WorkspaceSymbol[]} array of all symbols
*/
public getWorkspaceSymbols(query: string = ''): WorkspaceSymbol[] {
const workspace = workspaceManager.current;
logger.log({ searching: workspace?.path, query });
return this.globalSymbols.allSymbols
.filter(symbol => workspace?.contains(symbol.uri) || symbol.uri === workspace?.uri)
.map((s) => s.toWorkspaceSymbol())
.filter((symbol: WorkspaceSymbol) => {
return symbol.name.startsWith(query);
});
}
/**
* Utility function to get the definitions of a symbol at a given position.
*/
private getDefinitionHelper(document: LspDocument, position: Position): FishSymbol[] {
const symbols: FishSymbol[] = [];
const word = this.wordAtPoint(document.uri, position.line, position.character);
const node = this.nodeAtPoint(document.uri, position.line, position.character);
if (!word || !node) return [];
// First check local symbols
const localSymbols = this.getFlatDocumentSymbols(document.uri);
const localSymbol = localSymbols.find((s) => {
return s.name === word && containsRange(s.selectionRange, getRange(node));
});
if (localSymbol) {
symbols.push(localSymbol);
} else {
const toAdd: FishSymbol[] = localSymbols.filter((s) => {
const variableBefore = s.kind === SymbolKind.Variable ? precedesRange(s.selectionRange, getRange(node)) : true;
return (
s.name === word
&& containsRange(getRange(s.scope.scopeNode), getRange(node))
&& variableBefore
);
});
symbols.push(...toAdd);
}
// If no local symbols found, check sourced symbols
if (!symbols.length) {
const allAccessibleSymbols = this.allSymbolsAccessibleAtPosition(document, position);
const sourcedSymbols = allAccessibleSymbols.filter(s =>
s.name === word && s.uri !== document.uri,
);
symbols.push(...sourcedSymbols);
}
// Finally, check global symbols as fallback
if (!symbols.length) {
symbols.push(...this.globalSymbols.find(word));
}
return symbols;
}
/**
* Get the first definition of a position that we can find.
* Will first retrieve {@link Analyzer#getDefinitionHelper()} to look for possible definitions.
* Symbols found are then handled based on their node type, to ensure we return the most relevant definition.
* If symbol exists, but doesn't match any of the special cases, we return the last symbol found.
*/
public getDefinition(document: LspDocument, position: Position): FishSymbol | null {
const symbols: FishSymbol[] = this.getDefinitionHelper(document, position);
const word = this.wordAtPoint(document.uri, position.line, position.character);
const node = this.nodeAtPoint(document.uri, position.line, position.character);
if (node && isExportVariableDefinitionName(node)) {
return symbols.find(s => s.name === word) || symbols.pop()!;
}
if (node && isAliasDefinitionName(node)) {
return symbols.find(s => s.name === word) || symbols.pop()!;
}
if (node && isArgparseVariableDefinitionName(node)) {
const atPos = this.getFlatDocumentSymbols(document.uri).findLast(s =>
s.containsPosition(position) && s.fishKind === 'ARGPARSE',
) || symbols.pop()!;
return atPos;
}
if (node && isCompletionSymbol(node)) {
const completionSymbols = this.getFlatCompletionSymbols(document.uri);
const completionSymbol = completionSymbols.find(s => s.equalsNode(node));
if (!completionSymbol) {
return null;
}
const symbol = this.findSymbol((s) => completionSymbol.equalsArgparse(s));
if (symbol) return symbol;
}
if (node && isOption(node)) {
const symbol = this.findSymbol((s) => {
if (s.parent && s.fishKind === 'ARGPARSE') {
return node.parent?.firstNamedChild?.text === s.parent?.name &&
s.parent.isGlobal() &&
node.text.startsWith(s.argparseFlag);
}
return false;
});
if (symbol) return symbol;
}
return symbols.pop() || null;
}
/**
* Get all the definition locations of a position that we can find
*/
public getDefinitionLocation(document: LspDocument, position: Position): LSP.Location[] {
// handle source argument definition location
const node = this.nodeAtPoint(document.uri, position.line, position.character);
// check that the node (or its parent) is a `source` command argument
if (node && isSourceCommandArgumentName(node)) {
return this.getSourceDefinitionLocation(node, document);
}
if (node && node.parent && isSourceCommandArgumentName(node.parent)) {
return this.getSourceDefinitionLocation(node.parent, document);
}
// check if we have a symbol defined at the position
const symbol = this.getDefinition(document, position) as FishSymbol;
if (symbol) {
if (symbol.isEvent()) return [symbol.toLocation()];
const newSymbol = filterFirstPerScopeSymbol(document.uri)
.find((s) => s.equalDefinition(symbol));
if (newSymbol) return [newSymbol.toLocation()];
}
if (symbol) return [symbol.toLocation()];
// allow execCommandLocations to provide location for command when no other
// definition has been found. Previously, config.fish_lsp_single_workspace_support
// was used to prevent this case from being hit but now we always allow it.
if (workspaceManager.current) {
const node = this.nodeAtPoint(document.uri, position.line, position.character);
if (node && isCommandName(node)) {
const text = node.text.toString();
const locations = findCommandLocations(text);
return locations.map(({ uri }) =>
Location.create(uri, {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
}),
);
}
}
return [];
}
/**
* Here we can allow the user to use completion locations for the implementation.
*/
public getImplementation(document: LspDocument, position: Position): Location[] {
const definition = this.getDefinition(document, position);
if (!definition) return [];
const locations = getImplementation(document, position);
return locations;
}
/**
* Gets the location of the sourced file for the given source command argument name node.
*/
private getSourceDefinitionLocation(node: SyntaxNode, document: LspDocument): LSP.Location[] {
if (node && isSourceCommandArgumentName(node)) {
// Get the base directory for resolving relative paths
const fromPath = uriToPath(document.uri);
const baseDir = dirname(fromPath);
const expanded = getExpandedSourcedFilenameNode(node, baseDir) as string;
let sourceDoc = this.getDocumentFromPath(expanded);
if (!sourceDoc) {
this.analyzePath(expanded); // find the filepath & analyze it
sourceDoc = this.getDocumentFromPath(expanded); // reset the sourceDoc to new value
}
if (sourceDoc) {
return [
Location.create(sourceDoc!.uri, LSP.Range.create(0, 0, 0, 0)),
];
}
}
return [];
}
/**
* Get the hover from the given position in the document, if it exists.
* This is either a symbol, a manpage, or a fish-shell shipped function.
* Other hovers are shown are shown if this method can't find any (defined in `./hover.ts`).
*/
public getHover(document: LspDocument, position: Position): Hover | null {
const tree = this.getTree(document.uri);
const node = this.nodeAtPoint(document.uri, position.line, position.character);
if (!tree || !node) return null;
const symbol =
this.getDefinition(document, position) ||
this.globalSymbols.findFirst(node.text);
if (!symbol) return null;
logger.log(`analyzer.getHover: ${symbol.name}`, {
name: symbol.name,
uri: symbol.uri,
detail: symbol.detail,
text: symbol.node.text,
kind: symbolKindToString(symbol.kind),
});
return symbol.toHover();
}
/**
* Returns the tree-sitter tree for the given documentUri.
* If the document is not in the cache, it will cache it and return the tree.
*
* @NOTE: we use `documentUri` here instead of LspDocument's because it simplifies
* testing and is more consistently available in the server.
*
* @param documentUri - the uri of the document to get the tree for
* @return {Tree | undefined} - the tree for the document, or undefined if the document is not in the cache
*/
getTree(documentUri: string): Tree | undefined {
if (this.cache.hasUri(documentUri)) {
const doc = this.cache.getDocument(documentUri);
if (doc) {
return doc.ensureParsed().tree;
}
}
return this.analyzePath(uriToPath(documentUri))?.tree;
}
/**
* gets/finds the rootNode given a DocumentUri. if cached it will return the root from the cache,
* Otherwise it will analyze the path and return the root node, which might not be possible if the path
* is not readable or the file does not exist.
* @see {@link https://github.com/tree-sitter/tree-sitter/tree/master/lib/binding_web | web-tree-sitter's} {@link SyntaxNode}
* @param documentUri - the uri of the document to get the root node for
* @return {SyntaxNode | undefined} - the root node for the document, or undefined if the document is not in the cache
*/
getRootNode(documentUri: string): SyntaxNode | undefined {
if (this.cache.hasUri(documentUri)) {
const doc = this.cache.getDocument(documentUri);
if (doc) {
return doc.ensureParsed().root;
}
}
return this.analyzePath(uriToPath(documentUri))?.root;
}
/**
* Returns the document from the cache. If the document is not in the cache,
* it will return undefined.
*/
getDocument(documentUri: string): LspDocument | undefined {
return this.cache.getDocument(documentUri)?.document;
}
/**
* Returns the document from the cache if the document is in the cache.
*/
getDocumentFromPath(path: string): LspDocument | undefined {
const uri = pathToUri(path);
return this.getDocument(uri);
}
/**
* Returns the FishSymbol[] array in the cache for the given documentUri.
* The result is a nested array (tree) of FishSymbol[] items
*/
getDocumentSymbols(documentUri: string): FishSymbol[] {
return this.cache.getDocumentSymbols(documentUri);
}
/**
* Returns the flat array of FishSymbol[] for the given documentUri.
* Iterating through the result will allow you to reach every symbol in the documentUri.
*/
getFlatDocumentSymbols(documentUri: string): FishSymbol[] {
return this.cache.getFlatDocumentSymbols(documentUri);
}
/**
* Returns a list of symbols similar to a DocumentSymbol array, but
* instead of using that data type, we use our custom CompletionSymbol to define completions
*
* NOTE: while this method's visibility is public, it is really more of a utility
* for the `getGlobalArgparseLocations()` function in `src/parsing/argparse.ts`
*
* @param documentUri - the uri of the document to get the completions for
* @returns {CompletionSymbol[]} - an array of CompletionSymbol objects
*/
getFlatCompletionSymbols(documentUri: string): CompletionSymbol[] {
const doc = this.cache.getDocument(documentUri);
if (!doc) return [];
const { document, commandNodes } = doc;
// get the `complete` SyntaxNode[]
const childrenSymbols = commandNodes.filter(n => isCompletionCommandDefinition(n));
// build the CompletionSymbol[] for the entire document
const result: CompletionSymbol[] = [];
for (const child of childrenSymbols) {
result.push(...processCompletion(document, child));
}
return result;
}
/**
* Returns a list of all the nodes in the document.
*/
public nodesGen(documentUri: string): {
nodes: Generator;
namedNodes: Generator;
} {
const document = this.cache.getDocument(documentUri)?.document;
if (!document) {
return { nodes: (function* () { })(), namedNodes: (function* () { })() }; // Return an empty generator if the document is not found
}
const root = this.getRootNode(documentUri);
if (!root) {
return { nodes: (function* () { })(), namedNodes: (function* () { })() }; // Return an empty generator if the root node is not found
}
return {
nodes: nodesGen(root),
namedNodes: namedNodesGen(root),
};
}
/**
* Returns a list of all the nodes in the document.
*/
public getNodes(documentUri: string): SyntaxNode[] {
const document = this.cache.getDocument(documentUri)?.document;
if (!document) {
return [];
}
return getChildNodes(this.parser.parse(document.getText()).rootNode);
}
/**
* Returns a list of all the NAMED nodes in the document.
*/
public getNamedNodes(documentUri: string): SyntaxNode[] {
const document = this.cache.getDocument(documentUri)?.document;
if (!document) {
return [];
}
return getNamedChildNodes(this.parser.parse(document.getText()).rootNode);
}
/**
* Utility to collect all the sources in the input documentUri, or if specified
* it will only collect the included sources from the sources parameter
* @param documentUri - the uri of the document to collect sources from
* @param sources - the sources to collect from (optional set to narrow results)
* @returns {Set} - a flat set of all the sourceUri's reachable from the input sources
*/
public collectSources(
documentUri: string,
sources = this.cache.getSources(documentUri),
): Set {
const visited = new Set();
const collectionStack: string[] = Array.from(sources);
while (collectionStack.length > 0) {
const source = collectionStack.pop()!;
if (visited.has(source)) continue;
visited.add(source);
if (SyncFileHelper.isDirectory(uriToPath(source))) continue;
if (!SyncFileHelper.isFile(uriToPath(source))) continue;
const cahedSourceDoc = this.cache.hasUri(source)
? this.cache.getDocument(source) as AnalyzedDocument
: this.analyzePath(uriToPath(source)) as AnalyzedDocument;
if (!cahedSourceDoc) continue;
const sourced = this.cache.getSources(cahedSourceDoc.document.uri);
collectionStack.push(...Array.from(sourced));
}
return visited;
}
/**
* Collects all the sourceUri's that are reachable from the given documentUri at Position
* @param documentUri - the uri of the document to collect sources from
* @param position - the position to collect sources from
* @returns {Set} - a set of all the sourceUri's in the document before the position
*/
public collectReachableSources(documentUri: string, position: Position): Set {
const currentNode = this.nodeAtPoint(documentUri, position.line, position.character);
let currentParent: SyntaxNode | null;
if (currentNode) currentParent = findParentFunction(currentNode);
const sourceNodes = this.cache.getSourceNodes(documentUri)
.filter(node => {
if (isTopLevelDefinition(node) && isPositionAfter(getRange(node).start, position)) {
return true;
}
const parentFunction = findParentFunction(node);
if (currentParent && parentFunction?.equals(currentParent) && isPositionAfter(getRange(node).start, position)) {
return true;
}
return false;
});
const sources = new Set();
// Get the base directory for resolving relative paths
const fromPath = uriToPath(documentUri);
const baseDir = dirname(fromPath);
for (const node of sourceNodes) {
const sourced = getExpandedSourcedFilenameNode(node, baseDir);
if (sourced) {
sources.add(pathToUri(sourced));
}
}
return this.collectSources(documentUri, sources);
}
/**
* Collects all the sourceUri's that are in the documentUri
* @param documentUri - the uri of the document to collect sources from
* @returns {Set} - a set of all the sourceUri's in the document
*/
public collectAllSources(documentUri: string): Set {
const allSources = this.collectSources(documentUri);
for (const source of Array.from(allSources)) {
const sourceDoc = this.cache.getDocument(source);
if (!sourceDoc) {
this.analyzePath(source);
}
}
return allSources;
}
/**
* Collects all sourced symbols for a document, including symbols from all reachable source files.
* This is used for document symbols to include sourced functions and variables.
* @param documentUri - the uri of the document to collect sourced symbols for
* @returns {FishSymbol[]} - array of all sourced symbols (functions, variables) that should be visible
*/
public collectSourcedSymbols(documentUri: string): FishSymbol[] {
const sourcedSymbols: FishSymbol[] = [];
const uniqueNames = new Set();
// Get all sourced files reachable from this document
const sourcedUris = this.collectAllSources(documentUri);
for (const sourcedUri of sourcedUris) {
if (sourcedUri === documentUri) continue; // Skip self
// Create a mock SourceResource for symbolsFromResource
const sourceDoc = this.getDocument(sourcedUri);
if (!sourceDoc) continue;
const topLevelDefinitions = this.getFlatDocumentSymbols(sourceDoc.uri).filter(s => s.isRootLevel() || s.isGlobal());
sourcedSymbols.push(...topLevelDefinitions);
for (const resource of createSourceResources(analyzer, sourceDoc)) {
// If the resource is a sourced file, we can get its symbols
if (resource.to && resource.from && resource.node) {
const symbols = symbolsFromResource(this, resource, new Set(sourcedSymbols.map(s => s.name)))
.filter(s => s.isRootLevel() || s.isGlobal());
for (const symbol of symbols) {
if (!uniqueNames.has(symbol.name)) {
uniqueNames.add(symbol.name);
sourcedSymbols.push(symbol);
}
}
}
}
}
return sourcedSymbols;
}
/**
* Collects all reachable symbols for a document:
* - local defined symbols inside the document itself
* - all sourced symbols from reachable source files
*
* @param documentUri - the uri of the document to collect symbols for
* @returns {FishSymbol[]} - array of all reachable symbols
*/
public allReachableSymbols(documentUri: string): FishSymbol[] {
const seenSymbols = this.getFlatDocumentSymbols(documentUri);
analyzer.collectAllSources(documentUri).forEach((s) => {
const cached = analyzer.analyzeUri(s);
cached?.flatSymbols
.filter(s => s.isRootLevel() || s.isGlobal())
.filter(s => s.name !== 'argv')
.forEach(sym => {
seenSymbols.push(sym);
});
});
return seenSymbols;
}
/**
* Returns an object to be deconstructed, for the onComplete function in the server.
* This function is necessary because the normal onComplete parse of the LspDocument
* will commonly throw errors (user is incomplete typing a command, etc.). To avoid
* inaccurate parses for the entire document, we instead parse just the current line
* that the user is on, and send it to the shell script to complete.
*
* @Note: the position should not edited (pass in the direct position from the CompletionParams)
*
* @returns
* line - the string output of the line the cursor is on
* lineRootNode - the rootNode for the line that the cursor is on
* lineCurrentNode - the last node in the line
*/
public parseCurrentLine(
document: LspDocument,
position: Position,
): {
line: string;
word: string;
lineRootNode: SyntaxNode;
lineLastNode: SyntaxNode;
} {
const line = document
.getLineBeforeCursor(position)
.replace(/^(.*)\n$/, '$1') || '';
const word =
this.wordAtPoint(
document.uri,
position.line,
Math.max(position.character - 1, 0),
) || '';
const lineRootNode = this.parser.parse(line).rootNode;
const lineLastNode = lineRootNode.descendantForPosition({
row: 0,
column: line.length - 1,
});
return { line, word, lineRootNode, lineLastNode };
}
public wordAtPoint(
uri: string,
line: number,
column: number,
): string | null {
const node = this.nodeAtPoint(uri, line, column);
if (!node || node.childCount > 0 || node.text.trim() === '') {
return null;
}
// check if the current word is a node that contains a `=` sign, therefore
// we don't want to return the whole word, but only the part before the `=`
if (
isAliasDefinitionName(node) ||
isExportVariableDefinitionName(node)
) return node.text.split('=')[0]!.trim();
return node.text.trim();
}
/**
* Find the node at the given point.
*/
public nodeAtPoint(
uri: string,
line: number,
column: number,
): Parser.SyntaxNode | null {
const tree = this.cache.getParsedTree(uri);
if (!tree?.rootNode) {
// Check for lacking rootNode (due to failed parse?)
return null;
}
return tree.rootNode.descendantForPosition({ row: line, column });
}
/**
* Find the name of the command at the given point.
*/
public commandNameAtPoint(
uri: string,
line: number,
column: number,
): string | null {
let node = this.nodeAtPoint(uri, line, column);
while (node && !isCommand(node)) {
node = node.parent;
}
if (!node) return null;
const firstChild = node.firstNamedChild;
if (!firstChild || !isCommandName(firstChild)) return null;
return firstChild.text.trim();
}
public commandAtPoint(
uri: string,
line: number,
column: number,
): SyntaxNode | null {
const node = this.nodeAtPoint(uri, line, column) ?? undefined;
if (node && isCommand(node)) return node;
const parentCommand = findParentCommand(node);
return parentCommand;
}
/**
* Get the text at the given location, using the range of the location to find the text
* inside the range.
* Super helpful for debugging Locations like references, renames, definitions, etc.
*/
public getTextAtLocation(location: LSP.Location): string {
const document = this.cache.getDocument(location.uri);
if (!document) {
return '';
}
const text = document.document.getText(location.range);
return text;
}
public ensureCachedDocument(doc: LspDocument): AnalyzedDocument {
if (this.cache.hasUri(doc.uri)) {
const cachedDoc = this.cache.getDocument(doc.uri);
if (cachedDoc?.document.version === doc.version && cachedDoc.document.getText() === doc.getText()) {
return cachedDoc;
}
}
return this.analyze(doc);
}
private getIterableUris(): DocumentUri[] {
const currentWs = workspaceManager.current;
if (currentWs) {
return currentWs.uris.all;
}
return this.cache.uris();
}
}
/**
* @local
* @class GlobalDefinitionCache
*
* @summary The cache for all of the analyzer's global FishSymbol's across all workspaces
* analyzed.
*
* The enternal map uses the name of the symbol as the key, and the value is an array
* of FishSymbol's that have the same name. This is because a symbol can be defined
* multiple times in different scopes/workspaces, and we want to keep track of all of them.
*
* @see {@link analyzer.globalSymbols} the globally accessible location of this class
*/
class GlobalDefinitionCache {
constructor(private _definitions: Map = new Map()) { }
add(symbol: FishSymbol): void {
const current = this._definitions.get(symbol.name) || [];
if (!current.some(s => s.equals(symbol))) {
current.push(symbol);
}
this._definitions.set(symbol.name, current);
}
removeSymbolsByUri(uri: string): void {
for (const [name, symbols] of this._definitions.entries()) {
const filtered = symbols.filter(symbol => symbol.uri !== uri);
if (filtered.length === 0) {
this._definitions.delete(name);
} else {
this._definitions.set(name, filtered);
}
}
}
find(name: string): FishSymbol[] {
return this._definitions.get(name) || [];
}
findFirst(name: string): FishSymbol | undefined {
const symbols = this.find(name);
if (symbols.length === 0) {
return undefined;
}
return symbols[0];
}
has(name: string): boolean {
return this._definitions.has(name);
}
uniqueSymbols(): FishSymbol[] {
const unique: FishSymbol[] = [];
this.allNames.forEach(name => {
const u = this.findFirst(name);
if (u) {
unique.push(u);
}
});
return unique;
}
get allSymbols(): FishSymbol[] {
const all: FishSymbol[] = [];
for (const [_, symbols] of this._definitions.entries()) {
all.push(...symbols);
}
return all;
}
get allNames(): string[] {
return [...this._definitions.keys()];
}
get map(): Map {
return this._definitions;
}
}
/**
* @local
*
* @summary The cache for all of the analyzed documents in the server.
*
* @see {@link analyzer.cache} the globally accessible location of this class
* inside our analyzer instance
*
* The internal map uses the uri of the document as the key, and the value is
* the AnalyzedDocument object that contains:
* - LspDocument
* - FishSymbols (the definitions in the Document)
* - tree (from tree-sitter)
* - `source` command arguments, SyntaxNode[]
* - commands used in the document (array of strings)
*/
class AnalyzedDocumentCache {
constructor(private _documents: Map = new Map()) { }
uris(): string[] {
return [...this._documents.keys()];
}
setDocument(uri: URI, analyzedDocument: AnalyzedDocument): void {
this._documents.set(uri, analyzedDocument);
}
getDocument(uri: URI): AnalyzedDocument | undefined {
if (!this._documents.has(uri)) {
return undefined;
}
return this._documents.get(uri);
}
hasUri(uri: URI): boolean {
return this._documents.has(uri);
}
updateUri(oldUri: URI, newUri: URI): void {
const oldValue = this.getDocument(oldUri);
if (oldValue) {
this._documents.delete(oldUri);
this._documents.set(newUri, oldValue);
}
}
getDocumentSymbols(uri: URI): FishSymbol[] {
const doc = this._documents.get(uri);
if (doc) {
doc.ensureParsed();
return doc.documentSymbols;
}
return [];
}
getFlatDocumentSymbols(uri: URI): FishSymbol[] {
return this._documents.get(uri)?.flatSymbols || [];
}
getCommands(uri: URI): SyntaxNode[] {
const doc = this._documents.get(uri);
if (doc) {
doc.ensureParsed();
return doc.commandNodes;
}
return [];
}
getRootNode(uri: URI): Parser.SyntaxNode | undefined {
return this.getParsedTree(uri)?.rootNode;
}
getParsedTree(uri: URI): Parser.Tree | undefined {
const doc = this._documents.get(uri);
if (doc) {
doc.ensureParsed();
return doc.tree;
}
return undefined;
}
getSymbolTree(uri: URI): FishSymbol[] {
const analyzedDoc = this._documents.get(uri);
if (!analyzedDoc) {
return [];
}
analyzedDoc.ensureParsed();
return analyzedDoc.documentSymbols;
}
getSources(uri: URI): Set {
const analyzedDoc = this._documents.get(uri);
if (!analyzedDoc) {
return new Set();
}
analyzedDoc.ensureParsed();
const result: Set = new Set();
// Get the base directory for resolving relative paths
const fromPath = uriToPath(uri);
const baseDir = dirname(fromPath);
const sourceNodes = analyzedDoc.sourceNodes.map((node: any) => getExpandedSourcedFilenameNode(node, baseDir)).filter((s: any) => !!s) as string[];
for (const source of sourceNodes) {
const sourceUri = pathToUri(source);
result.add(sourceUri);
}
return result;
}
getSourceNodes(uri: URI): SyntaxNode[] {
const analyzedDoc = this._documents.get(uri);
if (!analyzedDoc) {
return [];
}
analyzedDoc.ensureParsed();
return analyzedDoc.sourceNodes;
}
clear(uri: URI) {
this._documents.delete(uri);
}
}
export function findCommandLocations(cmd: string) {
const paths: { path: string; uri: DocumentUri; }[] = env.findAutoloadedFunctionPath(cmd).map(filePath => ({
uri: pathToUri(filePath),
path: filePath,
}));
if (paths.length === 0) {
const potentialPaths = execCommandLocations(cmd).filter(p => {
if (p.path.startsWith('embedded:')) return false;
return SyncFileHelper.isFile(p.path);
});
paths.push(...potentialPaths);
}
return paths;
}
================================================
FILE: src/cli.ts
================================================
import './utils/polyfills';
import { BuildCapabilityString, PathObj, PackageLspVersion, PackageVersion, accumulateStartupOptions, FishLspHelp, FishLspManPage, SourcesDict, SubcommandEnv, CommanderSubcommand, getBuildTypeString, PkgJson } from './utils/commander-cli-subcommands';
import { Command, Option } from 'commander';
import { buildFishLspAbbreviations, buildFishLspCompletions } from './utils/get-lsp-completions';
import { logger } from './logger';
import { configHandlers, config, updateHandlers, validHandlers, Config, handleEnvOutput } from './config';
import { ConnectionOptions, ConnectionType, createConnectionType, maxWidthForOutput, startServer, timeServerStartup } from './utils/startup';
import { performHealthCheck } from './utils/health-check';
import { setupProcessEnvExecFile } from './utils/process-env';
import { handleCLiDumpParseTree, handleCLiDumpSemanticTokens, handleCLiDumpSymbolTree } from './utils/cli-dump-tree';
import PackageJSON from '@package';
import chalk from 'chalk';
import vfs from './virtual-fs';
/**
* creates local 'commandBin' used for commander.js
*/
const createFishLspBin = (): Command => {
const description = [
'Description:',
FishLspHelp().description || 'An LSP for the fish shell language',
].join('\n');
const bin = new Command('fish-lsp')
.description(description)
.helpOption('-h, --help', 'show the relevant help info. Other `--help-*` flags are also available.')
.version(PackageJSON.version, '-v, --version', 'output the version number')
.enablePositionalOptions(true)
.configureHelp({
showGlobalOptions: false,
sortSubcommands: true,
commandUsage: (_) => FishLspHelp().usage,
})
.showSuggestionAfterError(true)
.showHelpAfterError(true)
.addHelpText('after', FishLspHelp().after);
return bin;
};
// start adding options to the command
export const commandBin = createFishLspBin();
// hidden global options
commandBin
.addOption(new Option('--help-man', 'show special manpage output').hideHelp(true))
.addOption(new Option('--help-all', 'show all help info').hideHelp(true))
.addOption(new Option('--help-short', 'show mini help info').hideHelp(true))
.action(opt => {
if (opt.helpMan) {
const { path: _path, content } = FishLspManPage();
logger.logToStdout(content.join('\n').trim());
} else if (opt.helpAll) {
const globalOpts = [new Option('-h, --help', 'show help'), ...commandBin.options];
const allOpts = [
...globalOpts.map(o => o.flags),
...commandBin.commands.flatMap(c => c.options.map(o => o.flags)),
];
const padAmount = Math.max(...allOpts.map(o => `${o}\t`.length));
const subCommands = commandBin.commands.map((cmd) => {
return [
` ${cmd.name()} ${cmd.usage()}\t${cmd.summary()}`,
cmd.options.map(o => ` ${o.flags.padEnd(padAmount)}\t${o.description}`).join('\n'),
''].join('\n');
});
const { beforeAll, after } = FishLspHelp();
logger.logToStdout(['NAME:',
'fish-lsp - an lsp for the fish shell language',
'',
'USAGE: ',
beforeAll,
'',
'DESCRIPTION:',
' ' + commandBin.description().split('\n').slice(1).join('\n').trim(),
'',
'OPTIONS:',
' ' + globalOpts.map(o => ' ' + o.flags.padEnd(padAmount) + '\t' + o.description).join('\n').trim(),
'',
'SUBCOMMANDS:',
subCommands.join('\n'),
'',
'EXAMPLES:',
after.split('\n').slice(2).join('\n'),
].join('\n').trim());
} else if (opt.helpShort) {
logger.logToStdout([
'fish-lsp [OPTIONS]',
'fish-lsp [COMMAND] [OPTIONS]',
'',
commandBin.description(),
].join('\n'));
}
return;
});
// START
commandBin.command('start')
.summary('start the lsp')
.description('start the language server for a connection to a client')
.option('--dump', 'stop lsp & show the startup options being read')
.option('--enable ', 'enable the startup option')
.option('--disable ', 'disable the startup option')
.option('--stdio', 'use stdin/stdout for communication (default)')
.option('--node-ipc', 'use node IPC for communication')
.option('--socket ', 'use TCP socket for communication')
.option('--port ', 'use TCP socket for communication (alias for --socket)')
.option('--memory-limit ', 'set memory usage limit in MB')
.option('--max-files ', 'override the maximum number of files to analyze')
.option('--web', 'start the server in web playground mode (fish-lsp.dev/playground)')
.addHelpText('afterAll', [
'',
'Strings for \'--enable/--disable\' switches:',
`${validHandlers?.map((opt, index) => {
return index < validHandlers.length - 1 && index > 0 && index % 5 === 0 ? `${opt},\n` :
index < validHandlers.length - 1 ? `${opt},` : opt;
}).join(' ').split('\n').map(line => `\t${line.trim()}`).join('\n')}`,
'',
'Examples:',
'\t>_ fish-lsp start --disable hover # only disable the hover feature',
'\t>_ fish-lsp start --disable complete hover --dump',
'\t>_ fish-lsp start --enable --disable complete codeAction',
'\t>_ fish-lsp start --socket 3000 # start TCP server on port 3000 (useful for Docker)',
].join('\n'))
.allowUnknownOption(false)
.action(async (opts: CommanderSubcommand.start.schemaType) => {
await setupProcessEnvExecFile();
// NOTE: `config` is a global object, already initialized. Here, we are updating its
// values passed from the shell environment, and then possibly overriding them with
// the command line args.
// use the `config` object's shell environment values to update the handlers
updateHandlers(config.fish_lsp_enabled_handlers, true);
updateHandlers(config.fish_lsp_disabled_handlers, false);
// Handle max files option
if (opts.maxFiles && !isNaN(parseInt(opts.maxFiles))) {
config.fish_lsp_max_background_files = parseInt(opts.maxFiles);
}
//
// // Handle memory limit
if (opts.memoryLimit && !isNaN(parseInt(opts.memoryLimit))) {
const limitInMB = parseInt(opts.memoryLimit);
process.env.NODE_OPTIONS = `--max-old-space-size=${limitInMB}`;
}
//
// Determine connection type
const portValue = opts.port || opts.socket;
const connectionType: ConnectionType = createConnectionType({
stdio: opts.stdio,
nodeIpc: opts.nodeIpc,
pipe: !!portValue,
socket: false,
});
const connectionOptions: ConnectionOptions = {};
if (portValue) {
connectionOptions.port = parseInt(portValue, 10);
}
// override `configHandlers` with command line args
const { enabled, disabled, dumpCmd } = accumulateStartupOptions(commandBin.args);
updateHandlers(enabled, true);
updateHandlers(disabled, false);
Config.fixPopups(enabled, disabled);
// Set web playground mode if requested
if (opts.web) {
Config.isWebServer = true;
}
// Dump the configHandlers, if requested from the command line. This stops the server.
if (dumpCmd) {
logger.logFallbackToStdout({ handlers: configHandlers });
logger.logFallbackToStdout({ config: config });
process.exit(0);
}
/* config needs to be used in `startServer()` below */
startServer(connectionType, connectionOptions);
});
// INFO
commandBin.command('info')
.summary('show info about the fish-lsp')
.description('the info about the `fish-lsp` executable')
.option('--bin', 'show the path of the fish-lsp executable', false)
.option('--path', 'show the path of the entire fish-lsp repo', false)
.option('--build-time', 'show the path of the entire fish-lsp repo', false)
.option('--build-type', 'show the build type being used', false)
.option('-v, --version', 'show the version of the fish-lsp package', false)
.option('--lsp-version', 'show the lsp version', false)
.option('--capabilities', 'show the lsp capabilities', false)
.option('--man-file', 'show the man file path', false)
.option('--show', 'show the man file output', false)
.option('--logs-file', 'show the logs file path', false)
.option('--log-file', 'show the log file path', false)
.option('--verbose', 'show debugging server info (capabilities, paths, version, etc.)', false)
.option('--extra', 'show debugging server info (capabilities, paths, version, etc.)', false)
.option('--health-check', 'run diagnostics and report health status', false)
.option('--check-health', 'run diagnostics and report health status', false)
.option('--time-startup', 'time the startup of the fish-lsp executable', false)
.option('--time-only', 'alias to show only the time taken for the server to index files', false)
.option('--use-workspace ', 'use the specified workspace path for `fish-lsp info --time-startup`', undefined)
.option('--no-warning', 'do not show warnings in the output for `fish-lsp info --time-startup`', true)
.option('--show-files', 'show the files being indexed during `fish-lsp info --time-startup`', false)
.option('--source-maps', 'show source map information and management options', false)
.option('--all', 'show all source maps (use with --source-maps)', false)
.option('--all-paths', 'show the paths to all the source maps (use with --source-maps)', false)
.option('--install', 'download and install source maps (use with --source-maps)', false)
.option('--remove', 'remove source maps (use with --source-maps)', false)
.option('--check', 'check source map availability (use with --source-maps)', false)
.option('--status', 'show the status of all the source-maps available to the server (use with --source-maps)', false)
.option('--dump-parse-tree [FILE]', 'dump the tree-sitter parse tree of a file (reads from stdin if no file provided)', undefined)
.option('--dump-semantic-tokens [FILE]', 'dump the semantic tokens of a file (reads from stdin if no file provided)', undefined)
.option('--dump-symbol-tree [FILE]', 'dump the symbol tree of a file (reads from stdin if no file provided)', undefined)
.option('--no-color', 'disable color output for --dump-parse-tree, --dump-semantic-tokens, and --dump-symbol-tree', false)
.option('--no-icons', 'use plain text tags (f/v/e) instead of nerdfont icons for --dump-symbol-tree')
.option('--virtual-fs', 'show the virtual filesystem structure (like tree command)', false)
.allowUnknownOption(false)
// .allowExcessArguments(false)
.action(async (args: CommanderSubcommand.info.schemaType) => {
await setupProcessEnvExecFile();
const capabilities = BuildCapabilityString()
.split('\n')
.map((line: string) => ` ${line}`).join('\n');
const hasTimingOpts = args.timeStartup || args.timeOnly;
args.warning = !hasTimingOpts && args.warning === true ? !args.warning : args.warning;
// Variable to determine if we saw specific info requests
let shouldExit = false;
let exitCode = 0;
let argsCount = CommanderSubcommand.countArgsWithValues('info', args);
if (args.warning && !hasTimingOpts) {
argsCount = argsCount - 1;
}
const sourceMaps = CommanderSubcommand.info.sourcemaps();
// immediately exit if the user requested a specific info
CommanderSubcommand.info.handleBadArgs(args);
if (args.dumpParseTree) {
const status = await handleCLiDumpParseTree(args);
process.exit(status);
}
if (args.dumpSemanticTokens) {
const status = await handleCLiDumpSemanticTokens(args);
process.exit(status);
}
if (args.dumpSymbolTree) {
const status = await handleCLiDumpSymbolTree(args);
process.exit(status);
}
// If the user requested specific info, we will try to show only the requested output.
if (!args.verbose) {
// handle the preferred args (`--time-startup`, `--health-check`, `--check-health`)
if (args.timeStartup || args.timeOnly) {
await timeServerStartup({
workspacePath: args.useWorkspace,
warning: args.warning,
timeOnly: args.timeOnly,
showFiles: args.showFiles,
});
process.exit(0);
}
if (args.healthCheck || args.checkHealth) {
await performHealthCheck();
process.exit(0);
}
// Handle sourcemaps (requires --source-maps or specific sourcemap options)
if (args.sourceMaps) {
exitCode = CommanderSubcommand.info.handleSourceMaps(args);
shouldExit = true;
}
// normal info about the fish-lsp
if (args.bin) {
CommanderSubcommand.info.log(argsCount, 'Executable Path', PathObj.execFile);
shouldExit = true;
}
if (args.path) {
CommanderSubcommand.info.log(argsCount, 'Build Path', PathObj.path);
shouldExit = true;
}
if (args.buildTime) {
CommanderSubcommand.info.log(argsCount, 'Build Time', PkgJson.buildTime);
shouldExit = true;
}
if (args.buildType) {
CommanderSubcommand.info.log(argsCount, 'Build Type', getBuildTypeString());
shouldExit = true;
}
if (args.capabilities) {
CommanderSubcommand.info.log(argsCount, 'Capabilities', capabilities, true);
shouldExit = true;
}
if (args.version) {
CommanderSubcommand.info.log(argsCount, 'Build Version', PackageVersion);
shouldExit = true;
}
if (args.lspVersion) {
CommanderSubcommand.info.log(argsCount, 'LSP Version', PackageLspVersion, true);
shouldExit = true;
}
// handle `[--man-file | --log-file] (--show)?`
if (args.manFile || args.logFile || args.logsFile) {
exitCode = CommanderSubcommand.info.handleFileArgs(args) || 0;
shouldExit = true;
}
// handle `--virtual-fs`
if (args.virtualFs) {
argsCount = argsCount - 1;
const tree = vfs.displayTree();
CommanderSubcommand.info.log(argsCount, 'Virtual Filesystem', tree, true);
shouldExit = true;
}
}
if (!shouldExit || args.verbose) {
CommanderSubcommand.info.log(argsCount, 'Executable Path', PathObj.execFile, true);
CommanderSubcommand.info.log(argsCount, 'Build Location', PathObj.path, true);
CommanderSubcommand.info.log(argsCount, 'Build Version', PackageVersion, true);
CommanderSubcommand.info.log(argsCount, 'Build Time', PkgJson.buildTime, true);
CommanderSubcommand.info.log(argsCount, 'Build Type', getBuildTypeString(), true);
CommanderSubcommand.info.log(argsCount, 'Node Version', process.version, true);
CommanderSubcommand.info.log(argsCount, 'LSP Version', PackageLspVersion, true);
CommanderSubcommand.info.log(argsCount, 'Binary File', PathObj.bin, true);
CommanderSubcommand.info.log(argsCount, 'Man File', PathObj.manFile, true);
CommanderSubcommand.info.log(argsCount, 'Log File', config.fish_lsp_log_file, true);
CommanderSubcommand.info.log(argsCount, 'Sourcemaps', sourceMaps, true);
if (args.extra || args.capabilities || args.verbose) {
logger.logToStdout('_'.repeat(maxWidthForOutput()));
CommanderSubcommand.info.log(argsCount, 'Capabilities', capabilities, false);
}
}
process.exit(exitCode);
});
// URL
commandBin.command('url')
.summary('show helpful url(s) related to the fish-lsp')
.description('show the related url to the fish-lsp')
.option('--repo, --git', 'show the github repo')
.option('--npm', 'show the npm package url')
.option('--homepage', 'show the homepage')
.option('--contributions', 'show the contributions url')
.option('--wiki', 'show the github wiki')
.option('--issues, --report', 'show the issues page')
.option('--discussions', 'show the discussions page')
.option('--clients-repo', 'show the clients configuration repo')
.option('--sources-list', 'show a list of helpful sources')
.option('--source-map', 'show source map download url for current version')
.allowUnknownOption(false)
.allowExcessArguments(false)
.action(async (args: CommanderSubcommand.url.schemaType) => {
const amount = Object.keys(args).length;
if (amount === 0) {
logger.logToStdout('https://fish-lsp.dev');
process.exit(0);
}
Object.keys(args).forEach(key => logger.logToStdout(SourcesDict[key]?.toString() || ''));
process.exit(0);
});
// COMPLETE
commandBin.command('complete')
.summary('generate fish shell completions')
.description('the completions for the `fish-lsp` executable')
.option('--names', 'show the feature names of the completions')
.option('--names-with-summary', 'show names with their summary for a completions script')
.option('--toggles', 'show the feature names of the completions')
.option('--fish', 'show fish script')
.option('--features', 'show features')
.option('--env-variables', 'show env variables')
.option('--env-variable-names', 'show env variable names')
.option('--abbreviations', 'show abbreviations')
.description('copy completions output to fish-lsp completions file')
.allowUnknownOption(false)
.action(async (args: CommanderSubcommand.complete.schemaType) => {
await setupProcessEnvExecFile();
if (args.names) {
commandBin.commands.forEach(cmd => logger.logToStdout(cmd.name()));
process.exit(0);
} else if (args.namesWithSummary) {
commandBin.commands.forEach(cmd => logger.logToStdout(cmd.name() + '\t' + cmd.summary()));
process.exit(0);
} else if (args.fish) {
logger.logToStdout(buildFishLspCompletions(commandBin));
process.exit(0);
} else if (args.features || args.toggles) {
Object.keys(configHandlers).forEach((name) => logger.logToStdout(name.toString()));
process.exit(0);
} else if (args.envVariables) {
Object.entries(Config.envDocs).forEach(([key, value]) => {
logger.logToStdout(`${key}\\t'${value}'`);
});
process.exit(0);
} else if (args.envVariableNames) {
Object.keys(Config.envDocs).forEach((name) => logger.logToStdout(name.toString()));
process.exit(0);
} else if (args.abbreviations) {
logger.logToStdout(buildFishLspAbbreviations());
if (Object.values(args).filter(v => v === true).length === 1) process.exit(0);
}
logger.logToStdout(buildFishLspCompletions(commandBin));
process.exit(0);
});
// ENV
commandBin.command('env')
.summary('generate environment variables for lsp configuration')
.description('generate fish-lsp env variables')
.option('-c, --create', 'build initial fish-lsp env variables')
.option('-s, --show', 'show the current fish-lsp env variables')
.option('--show-default', 'show the default fish-lsp env variables')
.option('--only ', 'only show specified variables (comma-separated)')
.option('--no-comments', 'skip comments in output')
.option('--no-global', 'use local env variables')
.option('--no-local', 'do not use local scope for variables')
.option('--no-export', 'don\'t export the variables')
.option('--confd', 'output for piping to conf.d')
.option('--names', 'show only the variable names')
.option('--joined', 'print the names in a single line')
.option('--json', 'output in JSON format')
.allowUnknownOption(false)
.allowExcessArguments(false)
.action(async (args: SubcommandEnv.ArgsType) => {
await setupProcessEnvExecFile();
const outputType = SubcommandEnv.getOutputType(args);
const opts = SubcommandEnv.toEnvOutputOptions(args);
if (args.names) {
let result = '';
Object.keys(Config.envDocs).forEach((name) => {
if (args?.only && args.only.length > 0 && !args.only.includes(name)) {
logger.logToStderr(chalk.red(`\n[ERROR] Unknown variable name '${name} ' in --only option.`));
logger.logToStderr(`Valid variable names are:\n${Object.keys(Config.envDocs).join(', ')}`);
process.exit(1);
}
result += args.joined ? `${name} ` : `${name}\n`;
});
logger.logToStdout(result.trim());
process.exit(0);
}
handleEnvOutput(outputType, logger.logToStdout, opts);
process.exit(0);
});
// Parsing the command now happens in the `src / main.ts` file, since our bundler
export function execCLI() {
if (process.argv.length <= 2) {
logger.logToStderr(chalk.red('[ERROR] No COMMAND provided to `fish - lsp`, displaying `fish - lsp--help` output.\n'));
commandBin.outputHelp();
logger.logToStdout('\nFor more help, use `fish - lsp--help - all` to see all commands and options.');
process.exit(1);
}
// commandBin.parse(process.argv);
commandBin.parse();
}
================================================
FILE: src/code-actions/action-kinds.ts
================================================
import { CodeActionKind } from 'vscode-languageserver';
// Define our supported code action kinds
export const SupportedCodeActionKinds = {
QuickFix: `${CodeActionKind.QuickFix}.fix`,
Disable: `${CodeActionKind.QuickFix}.disable`,
QuickFixAll: `${CodeActionKind.QuickFix}.fixAll`,
QuickFixDelete: `${CodeActionKind.QuickFix}.delete`,
RefactorRewrite: `${CodeActionKind.Refactor}.rewrite`,
RefactorExtract: `${CodeActionKind.Refactor}.extract`,
SourceRename: `${CodeActionKind.Source}.rename`,
} as const;
export type SupportedCodeActionKinds = typeof SupportedCodeActionKinds[keyof typeof SupportedCodeActionKinds];
export const AllSupportedActions = Object.values(SupportedCodeActionKinds);
================================================
FILE: src/code-actions/alias-wrapper.ts
================================================
import * as os from 'os';
import { CodeAction, CreateFile, TextDocumentEdit, TextEdit, VersionedTextDocumentIdentifier, WorkspaceEdit } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { getRange } from '../utils/tree-sitter';
import { LspDocument } from '../document';
import { execAsyncF } from '../utils/exec';
import { join } from 'path';
import { SupportedCodeActionKinds } from './action-kinds';
import { pathToUri } from '../utils/translation';
/**
* Extracts the function name from an alias node
* ---
*
* ```fish
* # handles both cases
* alias name='cmd'
* alias name 'cmd'
* ```
*
* ---
* @param node The alias node
* @returns The function name
*/
function extractFunctionName(node: SyntaxNode): string {
const children = node.children;
if (children.length < 2) return '';
const nameNode = children[1];
if (!nameNode) return '';
// Handle both formats: alias name='cmd' and alias name 'cmd'
const name = nameNode.text.split('=')[0]?.toString() || '';
return name.trim();
}
/**
* Creates a quick-fix code action to convert an alias to a function inline
* This action will replace the alias line with the function content.
*/
export async function createAliasInlineAction(
doc: LspDocument,
node: SyntaxNode,
): Promise {
const aliasCommand = node.text;
const funcName = extractFunctionName(node);
if (!funcName) {
return undefined;
}
const stdout = await execAsyncF(`${aliasCommand} && functions ${funcName} | tail +2 | fish_indent`);
const edit = TextEdit.replace(
getRange(node),
`\n${stdout}\n`,
);
return {
title: `Convert alias '${funcName}' to inline function`,
kind: SupportedCodeActionKinds.RefactorExtract,
edit: {
changes: {
[doc.uri]: [edit],
},
},
isPreferred: true,
};
}
function createVersionedDocument(uri: string) {
return VersionedTextDocumentIdentifier.create(uri, 0);
}
function createFunctionFileEdit(functionUri: string, content: string) {
return TextDocumentEdit.create(
createVersionedDocument(functionUri),
[TextEdit.insert({ line: 0, character: 0 }, content)],
);
}
function createRemoveAliasEdit(document: LspDocument, node: SyntaxNode) {
return TextDocumentEdit.create(
createVersionedDocument(document.uri),
[TextEdit.del(getRange(node))],
);
}
/**
* Creates a quick-fix code action to convert an alias to a function file.
*/
export async function createAliasSaveActionNewFile(
doc: LspDocument,
node: SyntaxNode,
): Promise {
const aliasCommand = node.text;
const funcName = extractFunctionName(node);
// Get function content but remove first line (function declaration) and indent
const functionContent = await execAsyncF(`${aliasCommand} && functions ${funcName} | tail +2 | fish_indent`);
// Create path for new function file
const functionPath = join(os.homedir(), '.config', 'fish', 'functions', `${funcName}.fish`);
const functionUri = pathToUri(functionPath);
// const createFileAction = OptionalVersionedTextDocumentIdentifier.create(functionUri, null)
const createFileAction = CreateFile.create(functionUri, {
ignoreIfExists: false,
overwrite: true,
});
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
createFileAction,
createFunctionFileEdit(functionUri, functionContent),
createRemoveAliasEdit(doc, node),
],
};
return {
title: `Convert alias '${funcName}' to function in file: ~/.config/fish/functions/${funcName}.fish`,
kind: SupportedCodeActionKinds.RefactorExtract,
edit: workspaceEdit,
isPreferred: false,
};
}
/**
* Extra exports for testing purposes
*/
export const AliasHelper = {
extractFunctionName,
createAliasInlineAction,
createAliasSaveActionNewFile,
} as const;
================================================
FILE: src/code-actions/argparse-completions.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { findParentFunction, isCommandWithName, isFunctionDefinition, isString } from '../utils/node-types';
import { getChildNodes, getRange } from '../utils/tree-sitter';
import { LspDocument } from '../document';
import { ChangeAnnotation, CodeAction, CodeActionKind, TextDocumentEdit, TextEdit, VersionedTextDocumentIdentifier, WorkspaceEdit } from 'vscode-languageserver';
import { extractFunctionWithArgparseToCompletionsFile } from './refactors';
import { uriToReadablePath } from '../utils/translation';
import { logger } from '../logger';
import { findArgparseDefinitionNames } from '../parsing/argparse';
export type CompleteFlag = {
shortOption?: string;
longOption: string;
};
// export function parseArgparseFlag(text: string): CompleteFlag {
function parseArgparseFlag(node: SyntaxNode): CompleteFlag {
let text = node.text;
if (isString(node)) text = text.slice(1, -1);
// Remove any equals and following text
const beforeEquals = text.split('=')[0] as string;
// Check if it has a short/long split with '/'
if (beforeEquals.includes('/')) {
const [short, long] = beforeEquals.split('/') as [string, string];
return {
shortOption: short,
longOption: long === '' ? '' : long,
};
}
// No short option, just return as long option
return {
longOption: beforeEquals,
};
}
export function findFlagsToComplete(node: SyntaxNode): CompleteFlag[] {
if (!isCommandWithName(node, 'argparse')) return [];
const flags: CompleteFlag[] = [];
for (const n of findArgparseDefinitionNames(node)) {
flags.push(parseArgparseFlag(n));
}
return flags;
}
export function buildCompleteString(commandName: string, flags: CompleteFlag[]): string {
return flags.map(flag => {
let text = `complete -c ${commandName}`;
if (flag.shortOption) {
text += ` -s ${flag.shortOption}`;
}
if (flag.longOption) {
text += ` -l ${flag.longOption}`;
}
return text;
}).join('\n');
}
/**
* Helper function to build `argparse` completions for the current function in a
* `conf.d/file.fish` file.
* ___
* Some example input can be seen below:
* ___
* ```fish
* # ~/.config/fish/conf.d/file.fish
* function some_function
* argparse h/help o/option= v/verbose -- $argv
* or return
*
* echo 'do some stuff'
* end
* ```
* ___
* @param argparseNode The `argparse` node
* @param functionNode The `function_definition` node
* @param functionNameNode The `functionNode.firstNamedChild` node containing the name of the function
* @returns A `CodeAction` object to create the completions file
*/
function buildConfdCompletions(
argparseNode: SyntaxNode,
functionNode: SyntaxNode,
functionNameNode: SyntaxNode,
doc: LspDocument,
): CodeAction | undefined {
logger.log(buildConfdCompletions.name, 'params', {
argparseNode: argparseNode.text,
functionNode: functionNode.text,
functionNameNode: functionNameNode.text,
doc: doc.uri,
});
// get the path to the completions file. Should be in the conf.d directory
const completionPath = doc.getRelativeFilenameToWorkspace();
// get the flags and the function name
const flags = findFlagsToComplete(argparseNode);
if (!isFunctionDefinition(functionNode)) {
return undefined;
}
const functionName = functionNode.firstNamedChild!.text;
// build the `complete -c command -s -l` string
const completionText = buildCompleteString(functionName, flags);
// Get the text to insert
const selectedText = `\n# auto generated by fish-lsp\n${completionText}\n`;
const shortPath = doc.isFunced() || doc.isCommandlineBuffer()
? doc.getRelativeFilenameToWorkspace()
: uriToReadablePath(completionPath);
// Create a change annotation
const changeAnnotation: ChangeAnnotation = {
label: `Create completions for '${functionName}' in file: ${shortPath}`,
description: `Create completions for '${functionName}' to file: ${shortPath}`,
};
// build the workspace edit
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
TextDocumentEdit.create(
VersionedTextDocumentIdentifier.create(doc.uri, 0),
[TextEdit.insert(getRange(functionNode).end, selectedText)]),
],
changeAnnotations: { [changeAnnotation.label]: changeAnnotation },
};
logger.log(buildConfdCompletions.name, 'return', {
textEdits: workspaceEdit.documentChanges,
});
return {
title: `Create completions for '${functionName}' function`,
kind: CodeActionKind.QuickFix,
edit: workspaceEdit,
};
}
function getNodesForArgparse(selectedNode: SyntaxNode) {
const node = selectedNode;
if (isCommandWithName(node, 'argparse')) {
const functionNode = findParentFunction(node);
return {
argparseNode: node,
functionNode: functionNode,
functionNameNode: functionNode?.firstNamedChild,
};
}
if (node.type === 'word' && node.parent && isCommandWithName(node.parent, 'argparse')) {
const functionNode = findParentFunction(node.parent);
return {
argparseNode: node.parent,
functionNode: functionNode,
functionNameNode: functionNode?.firstNamedChild,
};
}
if (node.type === 'function_definition') {
return {
argparseNode: getChildNodes(node).find(n => isCommandWithName(n, 'argparse')),
functionNode: node,
functionNameNode: node.firstNamedChild,
};
}
return {
argparseNode: undefined,
functionNode: undefined,
functionNameNode: undefined,
};
}
export function createArgparseCompletionsCodeAction(
node: SyntaxNode,
doc: LspDocument,
): CodeAction | undefined {
const autoloadType = doc.getAutoloadType();
const { argparseNode, functionNode, functionNameNode } = getNodesForArgparse(node);
if (!argparseNode || !functionNode || !functionNameNode) return undefined;
if (autoloadType === 'functions') {
return extractFunctionWithArgparseToCompletionsFile(doc, getRange(functionNode), functionNode);
}
if (autoloadType === 'conf.d') {
const action = buildConfdCompletions(argparseNode, functionNode, functionNameNode, doc);
logger.log('buildConfdCompletions returned', { title: action?.title });
return action;
}
return undefined;
}
================================================
FILE: src/code-actions/code-action-handler.ts
================================================
import { CodeAction, CodeActionParams, Diagnostic, Range } from 'vscode-languageserver';
import { getDisableDiagnosticActions } from './disable-actions';
import { createFixAllAction, getQuickFixes } from './quick-fixes';
import { logger } from '../logger';
import { documents, LspDocument } from '../document';
import { analyzer, Analyzer } from '../analyze';
import { findFirstParent, getNodeAtRange } from '../utils/tree-sitter';
import { convertIfToCombiners, extractCommandToFunction, extractFunctionToFile, extractFunctionWithArgparseToCompletionsFile, extractToVariable, replaceAbsolutePathWithVariable, simplifySetAppendPrepend } from './refactors';
import { createArgparseCompletionsCodeAction } from './argparse-completions';
import { isCommandName, isCommandWithName, isProgram, isAliasDefinitionName, isCommand } from '../utils/node-types';
import { createAliasInlineAction, createAliasSaveActionNewFile } from './alias-wrapper';
import { SyntaxNode } from 'web-tree-sitter';
import { handleRedirectActions } from './redirect-actions';
/**
* Sort code actions by kind to group similar actions together
*/
function sortCodeActionsByKind(actions: CodeAction[]): CodeAction[] {
const kindOrder = {
'quickfix.disable': 0, // Disable comments first
'quickfix.fix': 1, // Then quick fixes
'quickfix.fixAll': 2, // Then fix all
'refactor.extract': 3, // Then extractions
'refactor.rewrite': 4, // Then rewrites (redirects, prefixes, etc.)
'source.rename': 5, // Then renames
};
return actions.sort((a, b) => {
const orderA = a.kind ? kindOrder[a.kind as keyof typeof kindOrder] ?? 999 : 999;
const orderB = b.kind ? kindOrder[b.kind as keyof typeof kindOrder] ?? 999 : 999;
return orderA - orderB;
});
}
/**
* Check if a range represents a selection (non-zero width)
*/
function isSelection(range: Range): boolean {
return range.start.line !== range.end.line ||
range.start.character !== range.end.character;
}
export function getParentCommandNodeForCodeAction(node: SyntaxNode | null): SyntaxNode | null {
if (!node) return null;
return findFirstParent(node, isCommand);
}
export function createCodeActionHandler() {
/**
* small helper for now, used to add code actions that are not `preferred`
* quickfixes to the list of results, when a quickfix is requested.
*/
async function getSelectionCodeActions(document: LspDocument, range: Range) {
const rootNode = analyzer.getRootNode(document.uri);
if (!rootNode) return [];
const selectedNode = getNodeAtRange(rootNode, range);
if (!selectedNode) return [];
logger.log('getSelectionCodeActions', {
selectedNodeType: selectedNode.type,
selectedNodeText: selectedNode.text.substring(0, 50),
isProgram: isProgram(selectedNode),
isCommandWithNameArgparse: isCommandWithName(selectedNode, 'argparse'),
parentType: selectedNode.parent?.type,
parentIsArgparse: selectedNode.parent ? isCommandWithName(selectedNode.parent, 'argparse') : false,
});
const commands: SyntaxNode[] = [];
const results: CodeAction[] = [];
if (isProgram(selectedNode)) {
const MAX_REDIRECT_COMMANDS = 2;
const cursorPosition = range.start;
const commandsForRedirect: SyntaxNode[] = [];
// First pass: collect all command nodes and handle argparse
analyzer.getNodes(document.uri).forEach(n => {
if (isCommandWithName(n, 'argparse')) {
const argparseAction = createArgparseCompletionsCodeAction(n, document);
if (argparseAction) results.push(argparseAction);
}
if (isCommandName(n) && !commands.some(c => n.id === c.id)) {
commands.push(n);
commandsForRedirect.push(n);
}
// if (isIfStatement(n)) {
// const convertIfAction = convertIfToCombiners(document, n, false);
// if (convertIfAction) results.push(convertIfAction);
// }
});
// Sort commands by distance to cursor and take the 2 closest
const closestCommands = commandsForRedirect
.sort((a, b) => {
const distA = Math.abs(a.startPosition.row - cursorPosition.line);
const distB = Math.abs(b.startPosition.row - cursorPosition.line);
return distA - distB;
})
.slice(0, MAX_REDIRECT_COMMANDS);
// Add redirect actions only for the closest commands
closestCommands.forEach(n => {
const redirectActions = handleRedirectActions(document, n.parent!);
if (redirectActions) results.push(...redirectActions);
});
}
// Note: Alias refactoring is handled in processRefactors to avoid duplication
// Note: extractCommandToFunction is handled in processRefactors to avoid duplication
if (isCommandWithName(selectedNode, 'argparse')) {
const argparseAction = createArgparseCompletionsCodeAction(selectedNode, document);
if (argparseAction) results.push(argparseAction);
} else if (selectedNode.parent && isCommandWithName(selectedNode.parent, 'argparse')) {
// Also handle when cursor is on a child of argparse command (e.g., on the word "argparse")
const argparseAction = createArgparseCompletionsCodeAction(selectedNode.parent, document);
if (argparseAction) results.push(argparseAction);
}
if (isCommandName(selectedNode) && !commands.some(c => selectedNode.id === c.id)) {
commands.push(selectedNode);
const redirectActions = handleRedirectActions(document, selectedNode.parent!);
if (redirectActions) results.push(...redirectActions);
}
// if (isCommand(selectedNode) || hasParent(selectedNode, isCommand) && !commands.some(c => selectedNode.id === c.id)) {
// commands.push(selectedNode);
// const addSilenceAction = silenceCommandAction(document, selectedNode);
// if (addSilenceAction) results.push(addSilenceAction);
// }
if (results.length === 0) {
logger.log('No selection code actions for node', selectedNode.type, selectedNode.text);
}
return results;
}
/**
* Helper to add quick fixes to the list that are mostly of the type `preferred`
*
* These quick fixes include things like `disable` actions, and general fixes to silence diagnostics
*/
async function processQuickFixes(document: LspDocument, diagnostics: Diagnostic[], analyzer: Analyzer) {
const results: CodeAction[] = [];
for (const diagnostic of diagnostics) {
logger.log('Processing diagnostic', diagnostic.code, diagnostic.message);
const quickFixs = await getQuickFixes(document, diagnostic, analyzer);
for (const fix of quickFixs) {
logger.log('QuickFix', fix?.title);
}
if (quickFixs) results.push(...quickFixs);
}
return results;
}
/**
* Process refactors for the given document and range
*/
async function processRefactors(document: LspDocument, range: Range) {
const results: CodeAction[] = [];
const rootNode = analyzer.getRootNode(document.uri);
if (!rootNode) return results;
// Get node at the selected range
const selectedNode = getNodeAtRange(rootNode, range);
if (!selectedNode) return results;
// try refactoring aliases first
let aliasCommand = selectedNode;
// Check if cursor is on the 'alias' keyword
if (selectedNode.text === 'alias') {
aliasCommand = selectedNode.parent!;
// Check if cursor is on the alias definition name (e.g., "foo" in "alias foo=bar")
} else if (isAliasDefinitionName(selectedNode)) {
aliasCommand = selectedNode.parent?.type === 'concatenation'
? selectedNode.parent.parent!
: selectedNode.parent!;
}
if (aliasCommand && isCommandWithName(aliasCommand, 'alias')) {
logger.log('isCommandWithName(alias)', aliasCommand.text);
const aliasInlineFunction = await createAliasInlineAction(document, aliasCommand);
const aliasNewFile = await createAliasSaveActionNewFile(document, aliasCommand);
if (aliasInlineFunction) results.push(aliasInlineFunction);
if (aliasNewFile) results.push(aliasNewFile);
return results;
}
// Try each refactoring action
// const extractFunction = extractToFunction(document, range);
// if (extractFunction) results.push(extractFunction);
// const selectedRange = isSelection(range) ? range : undefined;
// Pass range and selection info to extractCommandToFunction
if (!isSelection(range)) {
const extractCommandFunction = extractCommandToFunction(
document,
isSelection(range) ? range : undefined,
selectedNode,
);
if (extractCommandFunction) results.push(extractCommandFunction);
const extractVar = extractToVariable(document, range, selectedNode);
if (extractVar) results.push(extractVar);
}
const extractFuncToFile = extractFunctionToFile(document, range, selectedNode);
if (extractFuncToFile) results.push(extractFuncToFile);
const extractCompletionToFile = extractFunctionWithArgparseToCompletionsFile(document, range, selectedNode);
if (extractCompletionToFile) results.push(extractCompletionToFile);
const convertIf = convertIfToCombiners(document, selectedNode);
if (convertIf) results.push(convertIf);
const replacePathWithVarActions = replaceAbsolutePathWithVariable(document, range);
results.push(...replacePathWithVarActions);
const simplifySetActions = simplifySetAppendPrepend(document, selectedNode);
results.push(...simplifySetActions);
return results;
}
return async function handleCodeAction(params: CodeActionParams): Promise {
logger.debug('onCodeAction', {
params: {
context: {
only: params.context.only,
diagnostics: params.context.diagnostics.map(d => `${d.code}:${d.range.start.line}`),
triggerKind: params.context.triggerKind?.toString(),
},
uri: params.textDocument.uri,
range: params.range,
isSelection: isSelection(params.range),
},
});
const document = documents.get(params.textDocument.uri);
if (!document) return [];
const results: CodeAction[] = [];
// only process diagnostics from the fish-lsp source
const diagnostics = params.context.diagnostics
.filter(d => !!d?.severity)
.filter(d => d.source === 'fish-lsp');
// Check what kinds of actions are requested
const onlyRefactoring = params.context.only?.some(kind => kind.startsWith('refactor'));
const onlyQuickFix = params.context.only?.some(kind => kind.startsWith('quickfix'));
logger.log('Requested actions', { onlyRefactoring, onlyQuickFix });
logger.log('Diagnostics', diagnostics.map(d => ({ code: d.code, message: d.message })));
// Add disable actions
if (diagnostics.length > 0 && !onlyRefactoring) {
const disableActions = getDisableDiagnosticActions(document, diagnostics);
logger.log('Disable actions', disableActions.map(a => a.title));
for (const action of disableActions) {
if (results.every(existing => existing.title !== action.title)) {
results.push(action);
}
}
}
// Add quick fixes if requested
if (onlyQuickFix) {
logger.log('Processing onlyQuickFixes');
results.push(...await processQuickFixes(document, diagnostics, analyzer));
results.push(...await getSelectionCodeActions(document, params.range));
const allAction = createFixAllAction(document, results);
if (allAction) results.push(allAction);
logger.log('CodeAction results', results.map(r => r.title));
return sortCodeActionsByKind(results);
}
// add the refactors
if (onlyRefactoring) {
logger.log('Processing onlyRefactors');
results.push(...await processRefactors(document, params.range));
logger.log('CodeAction results', results.map(r => r.title));
return sortCodeActionsByKind(results);
}
logger.log('Processing all actions');
results.push(...await processQuickFixes(document, diagnostics, analyzer));
results.push(...await getSelectionCodeActions(document, params.range));
results.push(...await processRefactors(document, params.range));
const allAction = createFixAllAction(document, results);
if (allAction) {
logger.log({
name: 'allAction',
title: allAction.title,
kind: allAction.kind,
diagnostics: diagnostics?.map(d => d.message),
edit: allAction.edit,
});
results.push(allAction);
}
logger.log('CodeAction results', results.map(r => r.title));
return sortCodeActionsByKind(results);
};
}
export function equalDiagnostics(d1: Diagnostic, d2: Diagnostic) {
return d1.code === d2.code &&
d1.message === d2.message &&
d1.range.start.line === d2.range.start.line &&
d1.range.start.character === d2.range.start.character &&
d1.range.end.line === d2.range.end.line &&
d1.range.end.character === d2.range.end.character &&
d1.data.node?.text === d2.data.node?.text;
}
export function createOnCodeActionResolveHandler() {
return async function codeActionResolover(codeAction: CodeAction) {
return codeAction;
};
}
export function codeActionHandlers() {
return {
onCodeActionCallback: createCodeActionHandler(),
onCodeActionResolveCallback: createOnCodeActionResolveHandler(),
};
}
================================================
FILE: src/code-actions/combiner.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { getNamedChildNodes } from '../utils/tree-sitter';
import { isConditional } from '../utils/node-types';
/**
* Code Action utility function to convert an if_statement node into a sequence of combiner commands.
* ___
* ```fish
* if test -f file
* echo "file exists"
* else
* echo "file does not exist"
* end
* ```
* ___
* Would Become:
* ___
* ```fish
* test -f file
* and echo "file exists"
*
* or echo "file does not exist"
* ```
* ___
* @param node the if_statement node to convert into a sequence of combiner commands.
* @returns a string representation of the if_statement node, with the if/else-if/else blocks combined.
*/
export function convertIfToCombinersString(node: SyntaxNode) {
const combiner = new StatementCombiner();
const queue: SyntaxNode[] = getNamedChildNodes(node);
while (queue.length > 0) {
const n = queue.shift();
if (!n) break;
switch (true) {
case isConditional(n):
combiner.newBlock(n.type as BlockKeywordType);
break;
case n.type === 'negated_statement':
case n.type === 'conditional_execution':
combiner.appendCommand(n);
skipChildren(n, queue);
break;
case n.type === 'comment':
case n.type === 'command':
combiner.appendCommand(n);
break;
}
}
return combiner.build();
}
/**
* Utility function to skip children nodes that are not part of the current node.
*/
function skipChildren(node: SyntaxNode, queue: SyntaxNode[]) {
while (queue.length > 0) {
const peek = queue.at(0);
if (!peek) break;
if (peek.endIndex > node.endIndex || peek.startIndex < node.startIndex) break;
queue.shift();
}
}
/** Types of conditional blocks, defined in tree-sitter-fish grammar **/
type BlockKeywordType = 'if_statement' | 'else_if_clause' | 'else_clause';
/**
* Data structure to represent a conditional block in the fish language.
* A conditional block is a series of commands that are executed based on a condition.
* ___
* ```fish
* if test -f file
* echo "file exists"
* end
* ```
* ___
* Would become:
* ___
* ```typescript
* {
* keyword: 'if_statement',
* body: [
* { type: 'command', text: 'test -f file' },
* { type: 'command', text: 'echo "file exists"' }
* ],
* }
* ```
*/
interface ConditionalBlock {
/** the type of conditional block */
keyword: BlockKeywordType;
/** the commands that make up the conditional block */
body: SyntaxNode[];
}
namespace ConditionalBlock {
/**
* Creates a new conditional block. Typically the body will be empty, since
* a `if`/`else-if`/`else` block will always come before the body it contains.
* @param keyword The type of conditional block
* @param body The commands that make up the conditional block
* @returns The new conditional block
*/
export function create(keyword: BlockKeywordType, body: SyntaxNode[] = []) {
return { keyword, body };
}
}
/**
* Helper class to combine statements together, based on their conditional blocks.
*
* This class converts if/else-if/else blocks into a single string, with the
* appropriate combiners (and/or) between each block. Ideally, output from
* this class should keep the original control flow, while removing the
* if/else-if/else statements.
*/
class StatementCombiner {
private blocks: ConditionalBlock[] = [];
get currentBlock(): undefined | ConditionalBlock {
if (this.blocks.length === 0) {
return undefined;
}
return this.blocks[this.blocks.length - 1];
}
/**
* Creates a new block, based on the keyword type.
*/
newBlock(keywordType: 'if_statement' | 'else_if_clause' | 'else_clause') {
this.blocks.push(ConditionalBlock.create(keywordType));
}
/**
* Appends a node to the current block. Nodes should be non-leaf nodes for the
* most part because the `build()` method will use the `node.text` property to
* build combined strings. More specifically, the node's that are appended
* should group together child sections of each segment of the conditional
* sequence per if/else-if/else block.
* ___
* The supported possibilities for `node.type` are: `command`, `comment`, or `conditional_execution`
* ___
* NOTE: not calling `newBlock()` before this method will throw an error.
* ___
* @param node the node to append on the block's body.
*/
appendCommand(node: SyntaxNode) {
if (!this.currentBlock) {
throw new Error('Cannot append command to non-existent block, please create a new block first');
}
this.currentBlock.body.push(node);
}
/**
* Helper for retrieving the prefix combiner for a block, based on its keyword.
* The prefix is then used to combine the if/else-if/else blocks together.
* ___
* `if_statement` -> ''
* `else_if_clause` -> 'or '
* `else_clause` -> 'or '
* ___
* @param block The block to get the combiner for (which is the prefix )
* @returns The prefix/combiner for the block
*/
private getCombinerFromKeyword(block: ConditionalBlock) {
switch (block.keyword) {
case 'if_statement':
return '';
case 'else_if_clause':
case 'else_clause':
return 'or ';
}
}
/**
* Builds the string representation of a block, including the combiner and the comments
* @param block The block to build the string for
* @returns The string representation of the block
*/
private buildBlockString(block: ConditionalBlock) {
let str = this.getCombinerFromKeyword(block);
block.body.forEach((node, idx) => {
const nextNode = block.body.length - 1 >= idx
? block.body[idx + 1]
: undefined;
if (nextNode && nextNode.type === 'comment') {
str += node.text + '\n';
} else if (nextNode && nextNode.type === 'command') {
str += node.text + '\nand ';
} else {
str += node.text + '\n';
}
});
return str;
}
/**
* Builds the combined string of all the blocks
*/
build() {
return this.blocks
.map(block => this.buildBlockString(block))
.join('\n')
.trim();
}
}
================================================
FILE: src/code-actions/disable-actions.ts
================================================
// src/code-actions/disable-diagnostics.ts
import { CodeAction, Diagnostic, DiagnosticSeverity, TextEdit } from 'vscode-languageserver';
import { LspDocument } from '../document';
import { ErrorCodes } from '../diagnostics/error-codes';
import { SupportedCodeActionKinds } from './action-kinds';
import { logger } from '../logger';
interface DiagnosticGroup {
startLine: number;
endLine: number;
diagnostics: Diagnostic[];
}
function createDisableAction(
title: string,
document: LspDocument,
edits: TextEdit[],
diagnostics: Diagnostic[],
isPreferred: boolean = false,
): CodeAction {
return {
title,
kind: SupportedCodeActionKinds.Disable,
edit: {
changes: {
[document.uri]: edits,
},
},
diagnostics,
isPreferred,
};
}
export function handleDisableSingleLine(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
const indent = document.getIndentAtLine(diagnostic.range.start.line);
// Insert disable comment above the diagnostic line
const edit = TextEdit.insert(
{ line: diagnostic.range.start.line, character: 0 },
`${indent}# @fish-lsp-disable-next-line ${diagnostic.code}\n`,
);
const severity = ErrorCodes.getSeverityString(diagnostic.severity);
return createDisableAction(
`Disable ${severity} diagnostic ${diagnostic.code} for line ${diagnostic.range.start.line + 1}`,
document,
[edit],
[diagnostic],
);
}
export function handleDisableBlock(
document: LspDocument,
group: DiagnosticGroup,
): CodeAction {
const numbers = Array.from(new Set(group.diagnostics.map(diagnostic => diagnostic.code)).values()).join(' ');
const startIndent = document.getIndentAtLine(group.startLine);
const endIndent = document.getIndentAtLine(group.endLine);
const edits = [
// Insert disable comment at start of block
TextEdit.insert(
{ line: group.startLine, character: 0 },
`${startIndent}# @fish-lsp-disable ${numbers}\n`,
),
// Insert enable comment after end of block
TextEdit.insert(
{ line: group.endLine + 1, character: 0 },
`${endIndent}# @fish-lsp-enable ${numbers}\n`,
),
];
return {
...createDisableAction(
`Disable diagnostics ${numbers} in block (lines ${group.startLine + 1}-${group.endLine + 1})`,
document,
edits,
group.diagnostics,
),
};
}
// Group diagnostics that are adjacent or within N lines of each other
export function groupDiagnostics(diagnostics: Diagnostic[], maxGap: number = 1): DiagnosticGroup[] {
if (diagnostics.length === 0) return [];
// Sort diagnostics by starting line
const sorted = [...diagnostics].sort((a, b) =>
a.range.start.line - b.range.start.line,
);
const groups: DiagnosticGroup[] = [];
let currentGroup: DiagnosticGroup = {
startLine: sorted[0]!.range.start.line,
endLine: sorted[0]!.range.end.line,
diagnostics: [sorted[0]!],
};
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i]!;
const gap = current.range.start.line - currentGroup.endLine;
if (gap <= maxGap) {
// Add to current group
currentGroup.endLine = Math.max(currentGroup.endLine, current.range.end.line);
currentGroup.diagnostics.push(current);
} else {
// Start new group
groups.push(currentGroup);
currentGroup = {
startLine: current.range.start.line,
endLine: current.range.end.line,
diagnostics: [current],
};
}
}
// Add final group
groups.push(currentGroup);
return groups;
}
export function handleDisableEntireFile(
document: LspDocument,
diagnostics: Diagnostic[],
): CodeAction[] {
const results: CodeAction[] = [];
const diagnosticsCounts = new Map();
diagnostics.forEach(diagnostic => {
if (ErrorCodes.codeTypeGuard(diagnostic.code)) {
const code = ErrorCodes.getDiagnostic(diagnostic.code).code;
diagnosticsCounts.set(code, (diagnosticsCounts.get(code) || 0) + 1);
}
});
const matchingDiagnostics: Array = [];
diagnosticsCounts.forEach((count, code) => {
if (count >= 5) {
logger.log(`CODEACTION: Disabling ${count} ${code.toString()} diagnostics in file`);
}
matchingDiagnostics.push(code as ErrorCodes.CodeTypes);
});
if (matchingDiagnostics.length === 0) return results;
let tokenLine = 0;
let firstLine = document.getLine(tokenLine);
if (firstLine.startsWith('#!/')) {
tokenLine++;
}
firstLine = document.getLine(tokenLine);
const allNumbersStr = matchingDiagnostics.join(' ').trim();
if (!firstLine.startsWith('# @fish-lsp-disable')) {
const edits = [
TextEdit.insert(
{ line: tokenLine, character: 0 },
`# @fish-lsp-disable ${allNumbersStr}\n`,
),
];
results.push(
createDisableAction(
`Disable all diagnostics in file (${allNumbersStr.split(' ').join(', ')})`,
document,
edits,
diagnostics,
),
);
matchingDiagnostics.forEach(match => {
const severity = ErrorCodes.getSeverityString(ErrorCodes.getDiagnostic(match).severity);
results.push(
createDisableAction(
`Disable ${severity} ${match.toString()} diagnostics for entire file`,
document,
[
TextEdit.insert({ line: tokenLine, character: 0 },
`# @fish-lsp-disable ${match.toString()}\n`),
],
diagnostics,
),
);
});
}
return results;
}
export function getDisableDiagnosticActions(
document: LspDocument,
diagnostics: Diagnostic[],
): CodeAction[] {
const actions: CodeAction[] = [];
const fixedDiagnostics = diagnostics
.filter(diagnostic => !!diagnostic?.severity)
.filter(diagnostic =>
diagnostic?.source === 'fish-lsp' && diagnostic?.code !== ErrorCodes.invalidDiagnosticCode,
);
// Add single-line disable actions for each diagnostic
fixedDiagnostics
.filter(diagnostic =>
diagnostic?.severity === DiagnosticSeverity.Warning
|| diagnostic.code === ErrorCodes.sourceFileDoesNotExist,
).forEach(diagnostic => {
actions.push(handleDisableSingleLine(document, diagnostic));
});
// Add block disable actions for groups
const groups = groupDiagnostics(fixedDiagnostics);
groups.forEach(group => {
// Only create block actions for multiple diagnostics
if (group.diagnostics.length > 1) {
actions.push(handleDisableBlock(document, group));
}
});
actions.push(...handleDisableEntireFile(document, fixedDiagnostics));
return actions;
}
================================================
FILE: src/code-actions/quick-fixes.ts
================================================
import { ChangeAnnotation, CodeAction, Diagnostic, RenameFile, TextEdit, WorkspaceEdit } from 'vscode-languageserver';
import { LspDocument } from '../document';
import { ErrorCodes } from '../diagnostics/error-codes';
import { equalRanges, getChildNodes } from '../utils/tree-sitter';
import { SyntaxNode } from 'web-tree-sitter';
import { ErrorNodeTypes, getFishBuiltinEquivalentCommandName } from '../diagnostics/node-types';
import { SupportedCodeActionKinds } from './action-kinds';
import { logger } from '../logger';
import { analyzer, Analyzer } from '../analyze';
import { getRange } from '../utils/tree-sitter';
import { pathToRelativeFunctionName, uriToPath, uriToReadablePath } from '../utils/translation';
import { FishString } from '../parsing/string';
import { findParentCommand, isAliasDefinitionName, isArgparseVariableDefinitionName, isConditionalCommand, isFunctionDefinition, isFunctionDefinitionName, isVariableDefinitionName } from '../utils/node-types';
/**
* These quick-fixes are separated from the other diagnostic quick-fixes because
* future work will involve adding significantly more complex
* solutions here (atleast I hope. I definitely think fish uniquely has a lot
* of potential for how advancded quickfixes could become eventually).
*
* The quick-fixes located at disable-actions.ts are mainly for simple disabling
* of diagnostic messages.
*/
// Helper to create a QuickFix code action
function createQuickFix(
title: string,
diagnostic: Diagnostic,
edits: { [uri: string]: TextEdit[]; },
): CodeAction {
return {
title,
kind: SupportedCodeActionKinds.QuickFix.toString(),
isPreferred: true,
diagnostics: [diagnostic],
edit: { changes: edits },
};
}
/**
* Helper to create a QuickFix code action for fixing all problems
*/
export function createFixAllAction(
document: LspDocument,
actions: CodeAction[],
): CodeAction | undefined {
if (actions.length === 0) return undefined;
const fixableActions = actions.filter(action => {
return action.isPreferred && action.kind === SupportedCodeActionKinds.QuickFix;
});
for (const fixable of fixableActions) {
logger.info('createFixAllAction', { fixable: fixable.title });
}
if (fixableActions.length === 0) return undefined;
const resultEdits: { [uri: string]: TextEdit[]; } = {};
const diagnostics: Diagnostic[] = [];
for (const action of fixableActions) {
if (!action.edit || !action.edit.changes) continue;
const changes = action.edit.changes;
for (const uri of Object.keys(changes)) {
const edits = changes[uri];
if (!edits || edits.length === 0) continue;
if (!resultEdits[uri]) {
resultEdits[uri] = [];
}
const oldEdits = resultEdits[uri];
if (edits && edits?.length > 0) {
// Check each edit individually for duplicates
// Only skip if both range AND content are identical
for (const newEdit of edits) {
const isDuplicate = oldEdits.some(e =>
equalRanges(e.range, newEdit.range) && e.newText === newEdit.newText,
);
if (!isDuplicate) {
oldEdits.push(newEdit);
}
}
resultEdits[uri] = oldEdits;
diagnostics.push(...action.diagnostics || []);
}
}
}
const allEdits: TextEdit[] = [];
for (const uri in resultEdits) {
const edits = resultEdits[uri];
if (!edits || edits.length === 0) continue;
allEdits.push(...edits);
}
return {
title: `Fix all auto-fixable quickfixes (total fixes: ${allEdits.length}) (codes: ${diagnostics.map(d => d.code).join(', ')})`,
kind: SupportedCodeActionKinds.QuickFixAll,
diagnostics,
edit: {
changes: resultEdits,
},
data: {
isQuickFix: true,
documentUri: document.uri,
totalEdits: allEdits.length,
uris: Array.from(new Set(Object.keys(resultEdits))),
},
};
}
/**
* utility function to get the error node token
* Improved to handle all opening tokens defined in ErrorNodeTypes
*/
function getErrorNodeToken(node: SyntaxNode): string | undefined {
const { text, type } = node;
// Handle exact node type matches first (most reliable)
if (type in ErrorNodeTypes) {
return ErrorNodeTypes[type as keyof typeof ErrorNodeTypes];
}
// For ERROR nodes, we need to look at the actual content to determine the token
if (type === 'ERROR') {
// Look for unclosed quotes at the end of the text
if (text.endsWith('"') && !text.startsWith('"')) {
return '"';
}
if (text.endsWith("'") && !text.startsWith("'")) {
return "'";
}
// Look for unclosed quotes at the beginning
if (text.includes('"') && text.indexOf('"') === text.lastIndexOf('"')) {
return '"';
}
if (text.includes("'") && text.indexOf("'") === text.lastIndexOf("'")) {
return "'";
}
}
// Handle single character tokens that might be embedded in text
const singleCharTokens = ['"', "'", '{', '[', '('];
for (const token of singleCharTokens) {
if (text.includes(token)) {
// Check if it's an unclosed token by counting occurrences
let matches = 0;
if (token === '"') {
matches = (text.match(/"/g) || []).length;
} else if (token === "'") {
matches = (text.match(/'/g) || []).length;
} else {
matches = (text.match(new RegExp(`\\${token}`, 'g')) || []).length;
}
if (matches % 2 === 1) { // Odd number means unclosed
return ErrorNodeTypes[token as keyof typeof ErrorNodeTypes];
}
}
}
// Handle keyword tokens (function, while, if, for, begin, switch)
const keywordTokens = ['function', 'while', 'if', 'for', 'begin', 'switch'];
for (const token of keywordTokens) {
// Check if the text starts with the keyword followed by whitespace or end of string
const regex = new RegExp(`^${token}(?=\\s|$)`);
if (regex.test(text)) {
return ErrorNodeTypes[token as keyof typeof ErrorNodeTypes];
}
}
// Fallback to original logic for any remaining cases
const startTokens = Object.keys(ErrorNodeTypes);
for (const token of startTokens) {
if (text.startsWith(token)) {
return ErrorNodeTypes[token as keyof typeof ErrorNodeTypes];
}
}
return undefined;
}
export function handleMissingEndFix(
document: LspDocument,
diagnostic: Diagnostic,
analyzer: Analyzer,
): CodeAction | undefined {
const root = analyzer.getTree(document.uri)!.rootNode;
let errNode = root.descendantForPosition({ row: diagnostic.range.start.line, column: diagnostic.range.start.character })!;
// If we found an ERROR node, try to find the specific error token within it
if (errNode.type === 'ERROR') {
// Use findErrorCause to get the specific problematic node
const errorCause = findErrorCauseFromNode(errNode);
if (errorCause) {
errNode = errorCause;
}
}
const rawErrorNodeToken = getErrorNodeToken(errNode);
if (!rawErrorNodeToken) return undefined;
// Determine the appropriate insertion position and text based on token type
const insertionData = getTokenInsertionData(errNode, rawErrorNodeToken, document);
return {
title: `Add missing "${rawErrorNodeToken}"`,
diagnostics: [diagnostic],
kind: SupportedCodeActionKinds.QuickFix,
edit: {
changes: {
[document.uri]: [
TextEdit.insert(insertionData.position, insertionData.text),
],
},
},
};
}
/**
* Find the specific error cause within an ERROR node
*/
function findErrorCauseFromNode(errorNode: SyntaxNode): SyntaxNode | null {
// Look for unclosed quote tokens within the error node's children
for (const child of errorNode.children) {
if (child.type === '"' || child.type === "'" || child.text === '"' || child.text === "'") {
return child;
}
}
// If no specific token found, look at the text content
const text = errorNode.text;
if (text.includes('"') && text.indexOf('"') === text.lastIndexOf('"')) {
return errorNode; // Return the error node itself
}
if (text.includes("'") && text.indexOf("'") === text.lastIndexOf("'")) {
return errorNode; // Return the error node itself
}
return null;
}
/**
* Determines the appropriate insertion position and text for different token types
*/
function getTokenInsertionData(errNode: SyntaxNode, closingToken: string, document: LspDocument): {
position: { line: number; character: number; };
text: string;
} {
// Handle 'end' tokens (function, while, if, for, begin, switch)
if (closingToken === 'end') {
// For block statements, add 'end' on a new line with proper indentation
const line = errNode.endPosition.row;
const indentLevel = document.getIndentAtLine(errNode.startPosition.row);
return {
position: { line: line, character: errNode.endPosition.column },
text: `\n${indentLevel}end`,
};
}
// Handle quotes (', ")
if (closingToken === "'" || closingToken === '"') {
// For quotes, add the closing quote immediately after the current position
return {
position: { line: errNode.endPosition.row, character: errNode.endPosition.column },
text: closingToken,
};
}
// Handle brackets, braces, parentheses (], }, ))
if ([')', '}', ']'].includes(closingToken)) {
// For brackets/braces/parens, add the closing token immediately after
return {
position: { line: errNode.endPosition.row, character: errNode.endPosition.column },
text: closingToken,
};
}
// Fallback case
return {
position: { line: errNode.endPosition.row, character: errNode.endPosition.column },
text: closingToken,
};
}
export function handleExtraEndFix(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
// Simply delete the extra end
const edit = TextEdit.del(diagnostic.range);
return createQuickFix(
'Remove extra "end"',
diagnostic,
{
[document.uri]: [edit],
},
);
}
// Handle missing quiet option error
function handleMissingQuietError(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction | undefined {
// Add -q flag
const edit = TextEdit.insert(diagnostic.range.end, ' -q ');
return {
title: 'Add silence (-q) flag',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
command: {
command: 'editor.action.formatDocument',
title: 'Format Document',
},
isPreferred: true,
};
}
function handleZeroIndexedArray(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction | undefined {
return {
title: 'Convert zero-indexed array to one-indexed array',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [
TextEdit.del(diagnostic.range),
TextEdit.insert(diagnostic.range.start, '1'),
],
},
},
isPreferred: true,
};
}
function handleDotSourceCommand(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction | undefined {
const edit = TextEdit.replace(diagnostic.range, 'source');
return {
title: 'Convert dot source command to source',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
// fix cases like: -xU
function handleUniversalVariable(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
const text = document.getText(diagnostic.range);
let newText = text.replace(/U/g, 'g');
newText = newText.replace(/--universal/g, '--global');
const edit = TextEdit.replace(
{
start: diagnostic.range.start,
end: diagnostic.range.end,
},
newText,
);
return {
title: 'Convert universal scope to global scope',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
function handleExternalShellCommandInsteadOfBuiltin(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction | undefined {
// Replace the command with an external shell command
const node = analyzer.nodeAtPoint(document.uri, diagnostic.range.start.line, diagnostic.range.start.character);
if (!node) {
logger.warning('handleExternalShellCommandInsteadOfBuiltin: No node found for diagnostic', diagnostic);
return undefined;
}
const newCommandText = getFishBuiltinEquivalentCommandName(node);
if (!newCommandText) {
logger.warning('handleExternalShellCommandInsteadOfBuiltin: No equivalent command found for', node.text);
return undefined;
}
// Don't handle ambiguous commands
if (newCommandText.includes(' | ')) {
logger.warning('handleExternalShellCommandInsteadOfBuiltin: Command is ambiguous, skipping', newCommandText);
return undefined;
}
const edit = TextEdit.replace(
diagnostic.range,
newCommandText,
);
return {
title: `Convert external shell command "${node.text}" to fish builtin "${newCommandText}"`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
export function handleSingleQuoteVarFix(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
// Replace single quotes with double quotes
const text = document.getText(diagnostic.range);
const newText = text.replace(/\\/g, '\\\\').replace(/'/g, '"').replace(/\$/g, '\\$');
const edit = TextEdit.replace(
diagnostic.range,
newText,
);
return {
title: 'Convert to double quotes',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
export function handleTestCommandVariableExpansionWithoutString(
document: LspDocument,
diagnostic: Diagnostic,
): CodeAction {
return createQuickFix(
'Surround test string comparison with double quotes',
diagnostic,
{
[document.uri]: [
TextEdit.insert(diagnostic.range.start, '"'),
TextEdit.insert(diagnostic.range.end, '"'),
],
},
);
}
function handleMissingDefinition(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction {
// Create function definition with filename
const functionName = pathToRelativeFunctionName(document.uri);
const edit: TextEdit = {
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: `function ${functionName}\n # TODO: Implement function\nend\n`,
};
return {
title: `Create function '${functionName}'`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
function handleFilenameMismatch(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction | undefined {
const functionName = node.text;
const newUri = document.uri.replace(/[^/]+\.fish$/, `${functionName}.fish`);
if (document.getAutoloadType() !== 'functions') {
return;
}
const oldName = document.getAutoLoadName();
const oldFilePath = document.getFilePath();
const oldFilename = document.getFilename();
const newFilePath = uriToPath(newUri);
const annotation = ChangeAnnotation.create(
`rename ${oldFilename} to ${newUri.split('/').pop()}`,
true,
`Rename '${oldFilePath}' to '${newFilePath}'`,
);
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
RenameFile.create(document.uri, newUri, { ignoreIfExists: false, overwrite: true }),
],
changeAnnotations: {
[annotation.label]: annotation,
},
};
return {
title: `RENAME: '${oldFilename}' to '${functionName}.fish' (File missing function '${oldName}')`,
kind: SupportedCodeActionKinds.RefactorRewrite,
diagnostics: [diagnostic],
edit: workspaceEdit,
};
}
function handleCompletionFilenameMismatch(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction | undefined {
const functionName = FishString.fromNode(node);
const newUri = document.uri.replace(/[^/]+\.fish$/, `${functionName}.fish`);
if (document.getAutoloadType() !== 'completions') {
return;
}
const oldName = document.getAutoLoadName();
const oldFilePath = document.getFilePath();
const oldFilename = document.getFilename();
const newFilePath = uriToPath(newUri);
const annotation = ChangeAnnotation.create(
`rename ${oldFilename} to ${newUri.split('/').pop()}`,
true,
`Rename '${oldFilePath}' to '${newFilePath}'`,
);
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
RenameFile.create(document.uri, newUri, { ignoreIfExists: false, overwrite: true }),
],
changeAnnotations: {
[annotation.label]: annotation,
},
};
return {
title: `RENAME: '${oldFilename}' to '${functionName}.fish' (File missing completion '${oldName}')`,
kind: SupportedCodeActionKinds.RefactorRewrite,
diagnostics: [diagnostic],
edit: workspaceEdit,
};
}
function handleReservedKeyword(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction {
const replaceText = `__${node.text}`;
const changeAnnotation = ChangeAnnotation.create(
`rename ${node.text} to ${replaceText}`,
true,
`Rename reserved keyword function definition '${node.text}' to '${replaceText}' (line: ${node.startPosition.row + 1})`,
);
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.replace(getRange(node), replaceText),
],
},
changeAnnotations: {
[changeAnnotation.label]: changeAnnotation,
},
};
return {
title: `Rename reserved keyword '${node.text}' to '${replaceText}' (line: ${node.startPosition.row + 1})`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
isPreferred: true,
edit: workspaceEdit,
};
}
const getNodeType = (node: SyntaxNode) => {
if (isFunctionDefinitionName(node)) {
return 'function';
}
if (isArgparseVariableDefinitionName(node)) {
return 'argparse';
}
if (isAliasDefinitionName(node)) {
return 'alias';
}
if (isVariableDefinitionName(node)) {
return 'variable';
}
return 'unknown';
};
function handleUnusedSymbol(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction | undefined {
const nodeType = getNodeType(node);
if (nodeType === 'unknown') return undefined;
// Find the entire function definition to remove
let scopeNode = node;
while (scopeNode && !isFunctionDefinition(scopeNode)) {
scopeNode = scopeNode.parent!;
}
if (nodeType === 'function') {
const changeAnnotation = ChangeAnnotation.create(
`Removed unused function ${node.text}`,
true,
`Removed unused function '${node.text}', in file '${document.getFilePath()}' (line: ${node.startPosition.row + 1} - ${node.endPosition.row + 1})`,
);
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.del(getRange(scopeNode)),
],
},
changeAnnotations: {
[changeAnnotation.label]: changeAnnotation,
},
};
return {
title: `Remove unused function ${node.text} (line: ${node.startPosition.row + 1})`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: workspaceEdit,
};
}
if (nodeType === 'argparse') {
const parentCommand = findParentCommand(node);
if (!parentCommand) return undefined;
const changeAnnotation = ChangeAnnotation.create(
`Check if argparse variable ${node.text} is set`,
true,
`Check if argparse variable '${node.text}' is set, in file '${document.getFilePath()}' (line: ${node.startPosition.row + 1})`,
);
const symbol = analyzer.getDefinition(document, diagnostic.range.end);
if (!symbol) return undefined;
const indent = document.getIndentAtLine(parentCommand.endPosition.row);
const name = symbol.aliasedNames.length > 0
? symbol.aliasedNames.reduce((longest, current) => current.length > longest.length ? current : longest, '')
: symbol.name;
const insertText = [
'\n',
`if set -ql ${name}`,
' ',
'end',
].map(line => `${indent}${line}`).join('\n');
let parentNode = symbol.node;
if (parentNode && parentNode.nextNamedSibling && isConditionalCommand(parentNode.nextNamedSibling)) {
while (parentNode && parentNode.nextNamedSibling && isConditionalCommand(parentNode.nextNamedSibling)) {
parentNode = parentNode.nextNamedSibling;
}
}
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.insert(getRange(parentNode).end, insertText),
],
},
changeAnnotations: {
[changeAnnotation.label]: changeAnnotation,
},
};
return {
title: `Use \`argparse ${node.text}\` variable '${name}' if it's set in '${symbol.parent?.name || uriToReadablePath(document.uri)}'`,
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: workspaceEdit,
isPreferred: true,
};
}
return undefined;
}
function handleAddEndStdinToArgparse(diagnostic: Diagnostic, document: LspDocument): CodeAction {
const edit = TextEdit.insert(diagnostic.range.end, ' -- $argv');
return {
title: 'Add end stdin ` -- $argv` to argparse',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[document.uri]: [edit],
},
},
isPreferred: true,
};
}
function handleConvertDeprecatedFishLsp(diagnostic: Diagnostic, node: SyntaxNode, document: LspDocument): CodeAction {
// const value = document.getText(diagnostic.range);
logger.log({ name: 'handleConvertDeprecatedFishLsp', diagnostic: diagnostic.range, node: node.text });
const replaceText = node.text === 'fish_lsp_logfile' ? 'fish_lsp_log_file' : node.text;
const edit = TextEdit.replace(diagnostic.range, replaceText);
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [edit],
},
};
return {
title: 'Convert deprecated environment variable name',
kind: SupportedCodeActionKinds.QuickFix,
diagnostics: [diagnostic],
edit: workspaceEdit,
isPreferred: true,
};
}
export async function getQuickFixes(
document: LspDocument,
diagnostic: Diagnostic,
analyzer: Analyzer,
): Promise {
if (!diagnostic.code) return [];
logger.log({
code: diagnostic.code,
message: diagnostic.message,
severity: diagnostic.severity,
node: diagnostic.data.node.text,
range: diagnostic.range,
});
let action: CodeAction | undefined;
const actions: CodeAction[] = [];
const root = analyzer.getRootNode(document.uri);
let node = root;
if (root) {
node = getChildNodes(root).find(n =>
n.startPosition.row === diagnostic.range.start.line &&
n.startPosition.column === diagnostic.range.start.character);
}
logger.info('getQuickFixes', { code: diagnostic.code, message: diagnostic.message, node: node?.text });
switch (diagnostic.code) {
case ErrorCodes.missingEnd:
action = handleMissingEndFix(document, diagnostic, analyzer);
if (action) actions.push(action);
return actions;
case ErrorCodes.extraEnd:
action = handleExtraEndFix(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.missingQuietOption:
action = handleMissingQuietError(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.usedUnviersalDefinition:
action = handleUniversalVariable(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.usedExternalShellCommandWhenBuiltinExists:
action = handleExternalShellCommandInsteadOfBuiltin(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.dotSourceCommand:
action = handleDotSourceCommand(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.zeroIndexedArray:
action = handleZeroIndexedArray(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.singleQuoteVariableExpansion:
action = handleSingleQuoteVarFix(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.testCommandMissingStringCharacters:
action = handleTestCommandVariableExpansionWithoutString(document, diagnostic);
if (action) actions.push(action);
return actions;
case ErrorCodes.autoloadedFunctionMissingDefinition:
if (!node) return [];
return [handleMissingDefinition(diagnostic, node, document)];
case ErrorCodes.autoloadedFunctionFilenameMismatch:
if (!node) return [];
action = handleFilenameMismatch(diagnostic, node, document);
if (action) actions.push(action);
return actions;
case ErrorCodes.functionNameUsingReservedKeyword:
if (!node) return [];
return [handleReservedKeyword(diagnostic, node, document)];
// case ErrorCodes.unusedLocalFunction:
// if (!node) return [];
// return [handleUnusedFunction(diagnostic, node, document)];
case ErrorCodes.unusedLocalDefinition:
if (!node) return [];
action = handleUnusedSymbol(diagnostic, node, document);
if (action) actions.push(action);
return actions;
case ErrorCodes.autoloadedCompletionMissingCommandName:
if (!node) return [];
action = handleCompletionFilenameMismatch(diagnostic, node, document);
if (action) actions.push(action);
return actions;
case ErrorCodes.argparseMissingEndStdin:
action = handleAddEndStdinToArgparse(diagnostic, document);
if (action) actions.push(action);
return actions;
case ErrorCodes.fishLspDeprecatedEnvName:
if (!node) return [];
return [handleConvertDeprecatedFishLsp(diagnostic, node, document)];
default:
return actions;
}
}
================================================
FILE: src/code-actions/redirect-actions.ts
================================================
import { TextEdit } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { LspDocument } from '../document';
import { logger } from '../logger';
import { createRefactorAction } from './refactors';
import { SupportedCodeActionKinds } from './action-kinds';
import { findParentCommand, isCommand } from '../utils/node-types';
function selectCommandNode(node: SyntaxNode): SyntaxNode | null {
let cmd = node;
if (node.type !== 'command') {
cmd = findParentCommand(node) || node;
}
if (!cmd || !isCommand(cmd)) return null;
return cmd;
}
export function silenceCommandAction(
document: LspDocument,
selectedNode: SyntaxNode,
) {
logger.log('silence command', { document: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
const cmd = selectCommandNode(selectedNode);
if (!cmd) return;
const insertEdit = TextEdit.insert(
{ line: cmd.endPosition.row, character: cmd.endPosition.column },
' &>/dev/null',
);
return createRefactorAction(
`Silence command '${cmd.firstNamedChild!.text} &>/dev/null' (line: ${cmd.startPosition.row + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [insertEdit],
},
);
}
export function silenceStderrCommandAction(
document: LspDocument,
selectedNode: SyntaxNode,
) {
logger.log('silence stderr command', { document: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
const cmd = selectCommandNode(selectedNode);
if (!cmd) return;
const insertEdit = TextEdit.insert(
{ line: cmd.endPosition.row, character: cmd.endPosition.column },
' 2>/dev/null',
);
return createRefactorAction(
`Silence stderr of command '${cmd.firstNamedChild!.text} 2>/dev/null' (line: ${cmd.startPosition.row + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [insertEdit],
},
);
}
export function silenceStdoutCommandAction(
document: LspDocument,
selectedNode: SyntaxNode,
) {
logger.log('silence stdout command', { document: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
const cmd = selectCommandNode(selectedNode);
if (!cmd) return;
const insertEdit = TextEdit.insert(
{ line: cmd.endPosition.row, character: cmd.endPosition.column },
' >/dev/null',
);
return createRefactorAction(
`Silence stdout of command '${cmd.firstNamedChild!.text} >/dev/null' (line: ${cmd.startPosition.row + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [insertEdit],
},
);
}
export function redirectStoutToStder(
document: LspDocument,
selectedNode: SyntaxNode,
) {
logger.log('redirect stdout to stderr command', { document: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
const cmd = selectCommandNode(selectedNode);
if (!cmd) return;
const insertEdit = TextEdit.insert(
{ line: cmd.endPosition.row, character: cmd.endPosition.column },
' >&2',
);
return createRefactorAction(
`Redirect stdout to stderr of command '${cmd.firstNamedChild!.text} >&2' (line: ${cmd.startPosition.row + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [insertEdit],
},
);
}
export function handleRedirectActions(
document: LspDocument,
selectedNode: SyntaxNode,
) {
const actions = [];
const silenceAction = silenceCommandAction(document, selectedNode);
if (silenceAction) actions.push(silenceAction);
const silenceStderrAction = silenceStderrCommandAction(document, selectedNode);
if (silenceStderrAction) actions.push(silenceStderrAction);
const silenceStdoutAction = silenceStdoutCommandAction(document, selectedNode);
if (silenceStdoutAction) actions.push(silenceStdoutAction);
const redirectStdoutAction = redirectStoutToStder(document, selectedNode);
if (redirectStdoutAction) actions.push(redirectStdoutAction);
return actions;
}
================================================
FILE: src/code-actions/refactors.ts
================================================
import os from 'os';
import { ChangeAnnotation, CodeAction, CodeActionKind, CreateFile, Range, TextDocumentEdit, TextEdit, VersionedTextDocumentIdentifier, WorkspaceEdit } from 'vscode-languageserver';
import { LspDocument } from '../document';
import { SyntaxNode } from 'web-tree-sitter';
import { findEnclosingScope, getChildNodes, getRange } from '../utils/tree-sitter';
import { findParentCommand, isBlock, isCommand, isCommandWithName, isFunctionDefinitionName, isIfStatement, isPathNode, isProgram, isStatement } from '../utils/node-types';
import { SupportedCodeActionKinds } from './action-kinds';
import { convertIfToCombinersString } from './combiner';
import path from 'path';
import { formatTextWithIndents, pathToUri, uriToReadablePath } from '../utils/translation';
import { logger } from '../logger';
import { buildCompleteString, findFlagsToComplete } from './argparse-completions';
import { analyzer } from '../analyze';
import { env } from '../utils/env-manager';
import { getParentCommandNodeForCodeAction } from './code-action-handler';
/**
* Notice how this file compared to the other code-actions, uses a node as it's parameter
* This is because the reafactors are not based on diagnostics. However, if we need to use
* a diagnostic for some reason, we can always pass its `Document.data.node` property.
*
* This section is very much still a WIP, so there are definitely some improvements
* to be made.
*/
export function createRefactorAction(
title: string,
kind: CodeActionKind,
edits: { [uri: string]: TextEdit[]; },
preferredAction = false,
): CodeAction {
return {
title,
kind,
edit: { changes: edits },
isPreferred: preferredAction,
};
}
export function extractFunctionWithArgparseToCompletionsFile(
document: LspDocument,
range: Range,
node: SyntaxNode,
) {
logger.log('extractFunctionWithArgparseToCompletionsFile', { document: document.uri }, range, { node: { text: node.text, type: node.type } });
let selectedNode = node;
if (isFunctionDefinitionName(node)) {
selectedNode = node.parent!;
}
if (isCommandWithName(selectedNode, 'argparse') || selectedNode.text.startsWith('argparse')) {
selectedNode = findEnclosingScope(selectedNode);
}
if (selectedNode.type !== 'function_definition') return;
const argparseNode = getChildNodes(selectedNode).find(n => isCommandWithName(n, 'argparse'));
const hasArgparse = !!argparseNode;
if (!hasArgparse) return;
const functionName = getChildNodes(selectedNode).find(n => isFunctionDefinitionName(n))!.text;
const autoloadType = document.getAutoloadType();
/** cancel if we're not in an autoloaded file */
if (functionName !== document.getAutoLoadName() || !['functions', 'config.fish'].includes(autoloadType)) return;
const completionPath = path.join(os.homedir(), '.config', 'fish', 'completions', `${functionName}.fish`);
const completionUri = pathToUri(completionPath);
const completionFlags = findFlagsToComplete(argparseNode);
const completionText = buildCompleteString(functionName, completionFlags);
const shortPath = uriToReadablePath(completionPath);
const changeAnnotation: ChangeAnnotation = {
label: `Create completions for '${functionName}' in file: ${shortPath}`,
description: `Create completions for '${functionName}' to file: ${shortPath}`,
};
const createFileAction = CreateFile.create(completionUri, { ignoreIfExists: true, overwrite: false });
// Get the selected text
const selectedText = `\n# auto generated by fish-lsp\n${completionText}\n`;
const createFileEdit = TextDocumentEdit.create(
VersionedTextDocumentIdentifier.create(completionUri, 0),
[TextEdit.insert({ line: 0, character: 0 }, selectedText)]);
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
createFileAction,
createFileEdit,
],
changeAnnotations: { [changeAnnotation.label]: changeAnnotation },
};
return {
title: `Create completions for '${functionName}' in file: ${shortPath}`,
kind: SupportedCodeActionKinds.RefactorExtract,
edit: workspaceEdit,
} as CodeAction;
}
export function extractFunctionToFile(
document: LspDocument,
range: Range,
node: SyntaxNode,
) {
logger.log('extractFunctionToFile', { document: document.uri }, range, { node: { text: node.text, type: node.type } });
let selectedNode = node;
if (isFunctionDefinitionName(node)) {
selectedNode = node.parent!;
}
if (selectedNode.type !== 'function_definition') return;
const functionName = getChildNodes(selectedNode).find(n => isFunctionDefinitionName(n))!.text;
// cancel if we're already in the file
if (functionName === document.getAutoLoadName()) return;
const functionPath = path.join(os.homedir(), '.config', 'fish', 'functions', `${functionName}.fish`);
const functionUri = pathToUri(functionPath);
const shortPath = uriToReadablePath(functionPath);
const changeAnnotation: ChangeAnnotation = {
label: `Extract function '${functionName}' to file: ${shortPath}`,
description: `Extract function '${functionName}' to file: ${shortPath}`,
};
const createFileAction = CreateFile.create(functionUri, { ignoreIfExists: false, overwrite: true });
// Get the selected text
const selectedText = document.getText(getRange(selectedNode));
const createFileEdit = TextDocumentEdit.create(
VersionedTextDocumentIdentifier.create(functionUri, 0),
[TextEdit.insert({ line: 0, character: 0 }, selectedText)]);
const removeOldFunction = TextDocumentEdit.create(
VersionedTextDocumentIdentifier.create(document.uri, document.version),
[TextEdit.del(getRange(selectedNode))]);
const workspaceEdit: WorkspaceEdit = {
documentChanges: [
createFileAction,
createFileEdit,
removeOldFunction,
],
changeAnnotations: { [changeAnnotation.label]: changeAnnotation },
};
return {
title: `Extract function '${functionName}' to file: ${shortPath}`,
kind: SupportedCodeActionKinds.RefactorExtract,
edit: workspaceEdit,
} as CodeAction;
}
export function extractToFunction(
document: LspDocument,
range: Range,
): CodeAction | undefined {
logger.log('extractToFunction', { document: document.uri }, { range });
// Generate a unique function name
const functionName = `extracted_function_${Math.floor(Math.random() * 1000)}`;
// Get the selected text
const selectedText = document.getText(range);
// make sure we're not extracting nothing
if (selectedText.trim() === '' && document.getLine(range.start.line).trim() !== '') return;
const node = analyzer.nodeAtPoint(document.uri, range.start.line, range.start.character);
const goodTypes = [
isCommand,
isBlock,
isStatement,
(n: SyntaxNode) => isCommandWithName(n, 'alias'),
];
const badTypes = [
isProgram,
isFunctionDefinitionName,
(n: SyntaxNode) => n.type === 'function_definition',
];
const isGoodNode = (n: SyntaxNode | null) => {
if (!n) return false;
return goodTypes.some(fn => fn(n)) && !badTypes.some(fn => fn(n));
};
if (node && !isGoodNode(node)) {
return undefined;
}
const indent = document.getIndentAtLine(range.start.line);
// Create the new function
const functionText = [
`\n${indent}function ${functionName}`,
...selectedText.split('\n').map(line => `${indent} ${line}`), // Indent the function body
`${indent}end\n`,
].join('\n');
// Insert the new function before the current scope
const insertEdit = TextEdit.insert(
{ line: range.start.line, character: 0 },
`\n${functionText}\n`,
);
// Replace the selected text with a call to the new function
const replaceEdit = TextEdit.replace(range, `${functionName}`);
const truncatedSelectedText = selectedText.split(' ').slice(0, 2).join(' ').trimEnd();
const msgText = truncatedSelectedText.length > 10 ? `${truncatedSelectedText.slice(0, 10)}...` : truncatedSelectedText;
return createRefactorAction(
`Extract '${msgText}' to local function '${functionName}' (line: ${range.start.line + 1})`,
SupportedCodeActionKinds.RefactorExtract,
{
[document.uri]: [replaceEdit, insertEdit],
},
);
}
export function extractCommandToFunction(
document: LspDocument,
selectionRange: Range | undefined,
selectedNode: SyntaxNode,
) {
logger.log('extractCommandToFunction', { document: document.uri }, { selectionRange, selectedNode: { text: selectedNode.text, type: selectedNode.type } });
// Generate a unique function name
const functionName = `extracted_function_${Math.floor(Math.random() * 1000)}`;
let extractRange: Range;
let commandName: string;
// If there's a selection, use it directly
if (selectionRange) {
extractRange = selectionRange;
const selectedText = document.getText(selectionRange);
// Try to get a meaningful name from the first few words
const firstLine = selectedText.trim().split('\n')[0];
const words = firstLine?.split(/\s+/) || [];
commandName = words[0] || 'selection';
} else {
// Otherwise, fall back to finding the command node
const parentCmd = getParentCommandNodeForCodeAction(selectedNode);
if (parentCmd) selectedNode = parentCmd;
if (!selectedNode || !isCommand(selectedNode)) {
logger.warning({
action: 'extractCommandToFunction',
reason: 'not a command node',
});
return;
}
extractRange = getRange(selectedNode);
commandName = selectedNode.firstNamedChild?.text || 'command';
}
// Replace the selected text with a call to the new function
const callText = selectionRange ? `$(${functionName})` : `${functionName}`;
const repRange = selectionRange ? selectionRange : extractRange;
// Get the selected text
const selectedText = document.getText(repRange);
// Create the new function
const functionText = [
`\nfunction ${functionName}`,
...selectedText.split('\n').map(line => ` ${line}`), // Indent the function body
'end\n',
].join('\n');
const replaceEdit = TextEdit.replace(repRange, callText);
// Insert the new function at end of file
const insertEdit = TextEdit.insert(
{ line: document.lineCount, character: 0 },
`\n${functionText}\n`,
);
const title = selectionRange
? `Extract selected '${commandName}' to local function '${functionName}' (line ${extractRange.start.line + 1})`
: `Extract selected '${commandName}' command to local function '${functionName}' (line ${extractRange.start.line + 1})`;
return createRefactorAction(
title,
SupportedCodeActionKinds.RefactorExtract,
{
[document.uri]: [insertEdit, replaceEdit],
},
);
}
export function extractToVariable(
document: LspDocument,
range: Range,
selectedNode: SyntaxNode,
): CodeAction | undefined {
logger.log('extractToVariable', { document: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
// Only allow extracting commands or expressions
const parentCmd = getParentCommandNodeForCodeAction(selectedNode);
if (parentCmd) selectedNode = parentCmd;
if (!isCommand(selectedNode)) return undefined;
const newRange = getRange(selectedNode);
const selectedText = document.getText(newRange);
const varName = `extracted_var_${Math.floor(Math.random() * 1000)}`;
// Create variable declaration
const declaration = `set -l ${varName} (${selectedText})\n`;
// Replace original text with variable
const replaceEdit = TextEdit.replace(newRange, declaration);
return createRefactorAction(
`Extract selected '${selectedNode.firstNamedChild!.text}' command to local variable '${varName}' (line: ${newRange.start.line + 1})`,
SupportedCodeActionKinds.RefactorExtract,
{
[document.uri]: [replaceEdit],
},
);
}
export function convertIfToCombiners(
document: LspDocument,
selectedNode: SyntaxNode,
isSelected: boolean = true,
): CodeAction | undefined {
logger.log('convertIfToCombiners', { uri: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
let node = selectedNode;
if (node.type === 'if' && !isIfStatement(node)) {
node = node.parent!;
}
if (!isIfStatement(node)) return undefined;
const combinerString = convertIfToCombinersString(node);
// format the input with proper indentation, trimStart() because the range will include the leading whitespace
const formattedString = formatTextWithIndents(
document,
selectedNode.startPosition.row,
combinerString,
).trimStart();
const message = isSelected ?
`Convert selected if statement to conditionally executed statement (line: ${node.startPosition.row + 1})` :
`Convert if statement to conditionally executed statement (line: ${node.startPosition.row + 1})`;
return createRefactorAction(
message,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [TextEdit.replace(getRange(node), formattedString)],
},
true, // Mark as preferred action
);
}
/**
* Helper to check if a command is modifying PATH
*/
function isPathModifyingCommand(node: SyntaxNode): boolean {
const cmd = findParentCommand(node);
if (!cmd) return false;
const cmdName = cmd.firstNamedChild?.text;
if (!cmdName) return false;
// Check for fish_add_path command
if (cmdName === 'fish_add_path') return true;
// Check for set PATH commands
if (cmdName === 'set') {
const args = cmd.namedChildren.slice(1); // Skip the command name
// Look for PATH variable being set
for (const arg of args) {
if (arg.text === 'PATH' || arg.text === 'path') return true;
}
}
return false;
}
export function replaceAbsolutePathWithVariable(
document: LspDocument,
range: Range,
): CodeAction[] {
logger.log('replaceAbsolutePathWithVariable', { document: document.uri }, range);
const selectedText = document.getText(range);
const node = analyzer.nodeAtPoint(document.uri, range.start.line, range.start.character);
if (!node) return [];
if (!isPathNode(node)) {
logger.warning({
action: 'replaceAbsolutePathWithVariable',
reason: 'not a path node',
nodeText: node.text,
nodeType: node.type,
});
return []; // not a path node
}
if (!node.text.startsWith('/') || node.parent?.type === 'concatenation') {
logger.warning({
action: 'replaceAbsolutePathWithVariable',
reason: 'not absolute path or part of concatenation',
nodeText: node.text,
nodeParent: {
text: node.parent?.text,
type: node.parent?.type,
},
});
return []; // not an absolute path
}
// Check if this is a PATH-modifying command
const isModifyingPath = isPathModifyingCommand(node);
// Collect all matching variables with their array indices
const matches: Array<{ key: string; value: string; length: number; index: number | null; }> = [];
// Check each environment variable to find matching prefixes
for (const envKey of env.keys) {
// Skip PATH if we're in a PATH-modifying command
if (isModifyingPath && envKey === 'PATH') continue;
const envValues = env.getAsArray(envKey);
for (let i = 0; i < envValues.length; i++) {
const envValue = envValues[i];
// Skip empty values
if (!envValue || envValue.length === 0) continue;
// Check if the absolute path starts with this environment value
if (node.text.startsWith(envValue) && envValue.length > 1) {
// Don't add if it's an exact match to a PATH entry when modifying PATH
if (isModifyingPath && node.text === envValue) continue;
// Store index only if the variable has multiple values (fish uses 1-based indexing)
const arrayIndex = envValues.length > 1 ? i + 1 : null;
matches.push({
key: envKey,
value: envValue,
length: envValue.length,
index: arrayIndex,
});
}
}
}
// Add HOME replacement option
const homeValue = env.get('HOME');
if (homeValue && node.text.startsWith(homeValue)) {
// Add $HOME option if not already in matches
if (!matches.some(m => m.key === 'HOME')) {
matches.push({
key: 'HOME',
value: homeValue,
length: homeValue.length,
index: null,
});
}
}
// Sort matches by length (longest first)
matches.sort((a, b) => b.length - a.length);
// No matches found
if (matches.length === 0) return [];
const results: CodeAction[] = [];
const nodeRange = getRange(node);
// Create code actions for each match (limit to top 5 to avoid clutter)
const topMatches = matches.slice(0, 5);
for (const match of topMatches) {
const remainingPath = node.text.slice(match.value.length);
const needsSlash = remainingPath.length > 0 && !remainingPath.startsWith('/');
// Build variable reference with index if needed
const varRef = match.index !== null
? `$${match.key}[${match.index}]`
: `$${match.key}`;
// Create replacement text
const varReplacement = `${varRef}${needsSlash ? '/' : ''}${remainingPath}`;
// Replace the entire node text (not just the matched prefix)
results.push(createRefactorAction(
`Replace with '${varRef}${needsSlash ? '/' : ''}...' (line: ${range.start.line + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [TextEdit.replace(nodeRange, varReplacement)],
},
));
// For HOME, also offer tilde (~) replacement
if (match.key === 'HOME' && match.index === null) {
const tildeReplacement = `~${needsSlash ? '/' : ''}${remainingPath}`;
results.push(createRefactorAction(
`Replace with '~${needsSlash ? '/' : ''}...' (line: ${range.start.line + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [TextEdit.replace(nodeRange, tildeReplacement)],
},
));
}
}
logger.debug({
action: 'replaceAbsolutePathWithVariable',
nodeText: node.text,
selectedText,
matches: topMatches,
isModifyingPath,
resultsCount: results.length,
});
return results;
}
/**
* Simplifies set commands that manually append or prepend values
* - set VAR $VAR appended → set -a VAR appended
* - set VAR prepended $VAR → set --prepend VAR prepended
*/
export function simplifySetAppendPrepend(
document: LspDocument,
selectedNode: SyntaxNode,
): CodeAction[] {
logger.log('simplifySetAppendPrepend', { document: document.uri }, { selectedNode: { text: selectedNode.text, type: selectedNode.type } });
// Find the set command
let cmd = selectedNode;
if (selectedNode.type !== 'command') {
cmd = findParentCommand(selectedNode) || selectedNode;
}
if (!cmd || !isCommandWithName(cmd, 'set')) {
return [];
}
const results: CodeAction[] = [];
const cmdRange = getRange(cmd);
// Get all named children (arguments) of the set command
const args = cmd.namedChildren;
if (args.length < 2) return []; // Need at least 'set' and variable name
// Find where the variable name is (skip flags)
let varNameIndex = -1;
let varName = '';
const flags: string[] = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (!arg) continue;
if (arg.text.startsWith('-')) {
flags.push(arg.text);
} else {
varNameIndex = i;
varName = arg.text;
break;
}
}
if (varNameIndex === -1 || !varName) return [];
// Get the value arguments (everything after the variable name)
const valueArgs = args.slice(varNameIndex + 1);
if (valueArgs.length === 0) return [];
// Check for append pattern: set VAR $VAR value1 value2...
const firstValue = valueArgs[0];
if (!firstValue) return [];
const isAppendPattern = firstValue.text === `$${varName}` || firstValue.text === `\$${varName}`;
// Check for prepend pattern: set VAR value1 value2... $VAR
const lastValue = valueArgs[valueArgs.length - 1];
if (!lastValue) return [];
const isPrependPattern = lastValue.text === `$${varName}` || lastValue.text === `\$${varName}`;
// Append pattern: set VAR $VAR appended → set -a VAR appended
if (isAppendPattern && valueArgs.length > 1) {
const remainingValues = valueArgs.slice(1); // Skip $VAR
const newFlags = [...flags, '-a'].join(' ');
const newValues = remainingValues.map(v => v.text).join(' ');
const replacement = `set ${newFlags} ${varName} ${newValues}`.trim().replace(/\s+/g, ' ');
results.push(createRefactorAction(
`Simplify to 'set -a ${varName} ...' (line: ${cmdRange.start.line + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [TextEdit.replace(cmdRange, replacement)],
},
true, // Mark as preferred
));
}
// Prepend pattern: set VAR prepended $VAR → set --prepend VAR prepended
if (isPrependPattern && valueArgs.length > 1) {
const remainingValues = valueArgs.slice(0, -1); // Skip last $VAR
const newFlags = [...flags, '--prepend'].join(' ');
const newValues = remainingValues.map(v => v.text).join(' ');
const replacement = `set ${newFlags} ${varName} ${newValues}`.trim().replace(/\s+/g, ' ');
results.push(createRefactorAction(
`Simplify to 'set --prepend ${varName} ...' (line: ${cmdRange.start.line + 1})`,
SupportedCodeActionKinds.RefactorRewrite,
{
[document.uri]: [TextEdit.replace(cmdRange, replacement)],
},
true, // Mark as preferred
));
}
logger.debug({
action: 'simplifySetAppendPrepend',
cmdText: cmd.text,
varName,
valueArgs: valueArgs.map(v => v.text),
isAppendPattern,
isPrependPattern,
resultsCount: results.length,
});
return results;
}
================================================
FILE: src/code-lens.ts
================================================
import { CodeLens } from 'vscode-languageserver';
import { Analyzer } from './analyze';
import { LspDocument } from './document';
import { getReferences } from './references';
import { uriToPath } from './utils/translation';
export function getReferenceCountCodeLenses(analyzer: Analyzer, document: LspDocument): CodeLens[] {
const codeLenses: CodeLens[] = [];
// Filter for global symbols
const globalSymbols = analyzer.getFlatDocumentSymbols(document.uri)
.filter(symbol => symbol.fishKind === 'FUNCTION');
// Create a code lens for each global symbol
for (const symbol of globalSymbols) {
// Get reference count
const references = getReferences(document, symbol.selectionRange.start) || [];
const referencesCount = references.length;
codeLenses.push({
range: symbol.range,
command: {
title: `${referencesCount} references`,
command: 'fish-lsp.showReferences',
arguments: [uriToPath(document.uri), symbol.selectionRange.start, references],
},
});
}
return codeLenses;
}
================================================
FILE: src/command.ts
================================================
import { Connection, ExecuteCommandParams, MessageType, /** Position, */ Range, Location, TextEdit, WorkspaceEdit, /** ProgressToken,*/ Position } from 'vscode-languageserver';
import { analyzer } from './analyze';
import { codeActionHandlers } from './code-actions/code-action-handler';
import { createFixAllAction } from './code-actions/quick-fixes';
import { Config, config, EnvVariableTransformers, getDefaultConfiguration, handleEnvOutput } from './config';
import { getDiagnosticsAsync } from './diagnostics/validate';
import { documents } from './document';
import { buildExecuteNotificationResponse, execEntireBuffer, fishLspPromptIcon, useMessageKind } from './execute-handler';
import { logger } from './logger';
import { env } from './utils/env-manager';
import { execAsync, execAsyncF, execAsyncFish } from './utils/exec';
import { EnvVariableJson, PrebuiltDocumentationMap } from './utils/snippets';
import { pathToUri, uriToPath, uriToReadablePath } from './utils/translation';
import { getRange } from './utils/tree-sitter';
import { workspaceManager } from './utils/workspace-manager';
import { PkgJson } from './utils/commander-cli-subcommands';
import FishServer from './server';
import { SyncFileHelper } from './utils/file-operations';
// Define command name constants to avoid string literals
export const CommandNames = {
EXECUTE_RANGE: 'fish-lsp.executeRange',
EXECUTE_LINE: 'fish-lsp.executeLine',
EXECUTE: 'fish-lsp.execute',
EXECUTE_BUFFER: 'fish-lsp.executeBuffer',
CREATE_THEME: 'fish-lsp.createTheme',
SHOW_STATUS_DOCS: 'fish-lsp.showStatusDocs',
SHOW_WORKSPACE_MESSAGE: 'fish-lsp.showWorkspaceMessage',
UPDATE_WORKSPACE: 'fish-lsp.updateWorkspace',
FIX_ALL: 'fish-lsp.fixAll',
TOGGLE_SINGLE_WORKSPACE_SUPPORT: 'fish-lsp.toggleSingleWorkspaceSupport',
GENERATE_ENV_VARIABLES: 'fish-lsp.generateEnvVariables',
SHOW_ENV_VARIABLES: 'fish-lsp.showEnvVariables',
CHECK_HEALTH: 'fish-lsp.checkHealth',
SHOW_REFERENCES: 'fish-lsp.showReferences',
SHOW_INFO: 'fish-lsp.showInfo',
} as const;
export const LspCommands = [...Array.from(Object.values(CommandNames))];
export type CommandName = typeof CommandNames[keyof typeof CommandNames];
// Type for command arguments
export type CommandArgs = {
// All commands now use variadic string[] with parser functions
[CommandNames.EXECUTE_RANGE]: string[]; // [path, "start,end"] or [path, start, end]
[CommandNames.EXECUTE_LINE]: string[]; // [path, line]
[CommandNames.EXECUTE]: string[]; // [path] (alias for EXECUTE_BUFFER)
[CommandNames.EXECUTE_BUFFER]: string[]; // [path]
[CommandNames.CREATE_THEME]: string[]; // [path, asVariables?]
[CommandNames.SHOW_STATUS_DOCS]: [statusCode: string]; // Not converted yet
[CommandNames.SHOW_WORKSPACE_MESSAGE]: [];
[CommandNames.UPDATE_WORKSPACE]: string[]; // [path, ...flags]
[CommandNames.FIX_ALL]: string[]; // [path]
[CommandNames.TOGGLE_SINGLE_WORKSPACE_SUPPORT]: [];
[CommandNames.GENERATE_ENV_VARIABLES]: string[]; // [path]
[CommandNames.SHOW_REFERENCES]: string[]; // [symbolName] or [path, line, char] or [path, "line,char"]
[CommandNames.SHOW_INFO]: [];
[CommandNames.SHOW_ENV_VARIABLES]: string[]; // [...opts]
};
// Command help messages for user-facing documentation
const CommandHelpMessages = {
[CommandNames.EXECUTE_RANGE]: {
usage: [
'fish-lsp.executeRange ,',
'fish-lsp.executeRange ',
],
examples: [
'fish-lsp.executeRange ~/.config/fish/config.fish 1,10',
'fish-lsp.executeRange ~/.config/fish/config.fish 1 10',
'fish-lsp.executeRange $XDG_CONFIG_HOME/fish/config.fish 5 15',
],
description: 'Execute a range of lines from a Fish script',
},
[CommandNames.EXECUTE_LINE]: {
usage: 'fish-lsp.executeLine ',
examples: [
'fish-lsp.executeLine ~/.config/fish/config.fish 7',
'fish-lsp.executeLine /path/to/script.fish 42',
],
description: 'Execute a single line from a Fish script',
},
[CommandNames.EXECUTE_BUFFER]: {
usage: 'fish-lsp.executeBuffer ',
examples: [
'fish-lsp.executeBuffer ~/.config/fish/config.fish',
],
description: 'Execute the entire Fish script buffer',
},
[CommandNames.CREATE_THEME]: {
usage: 'fish-lsp.createTheme [asVariables]',
examples: [
'fish-lsp.createTheme ~/.config/fish/theme.fish',
'fish-lsp.createTheme ~/theme.fish true',
],
description: 'Create a Fish theme configuration file',
},
[CommandNames.SHOW_STATUS_DOCS]: {
usage: 'fish-lsp.showStatusDocs ',
examples: [
'fish-lsp.showStatusDocs 0',
'fish-lsp.showStatusDocs 127',
],
description: 'Show documentation for a Fish exit status code',
},
[CommandNames.FIX_ALL]: {
usage: 'fish-lsp.fixAll ',
examples: [
'fish-lsp.fixAll ~/.config/fish/config.fish',
],
description: 'Apply all available quick fixes to a Fish script',
},
[CommandNames.SHOW_REFERENCES]: {
usage: [
'fish-lsp.showReferences ',
'fish-lsp.showReferences ,',
'fish-lsp.showReferences ',
],
examples: [
'fish-lsp.showReferences my_function',
'fish-lsp.showReferences ~/.config/fish/config.fish 7,10',
'fish-lsp.showReferences $XDG_CONFIG_HOME/fish/config.fish 7 10',
'fish-lsp.showReferences /absolute/path/to/file.fish 7 10',
],
description: 'Find all references to a symbol or location in Fish scripts',
},
} as const;
// Helper to format command help message
function formatCommandHelp(commandName: CommandName, reason?: string): string {
const help = CommandHelpMessages[commandName as keyof typeof CommandHelpMessages];
if (!help) {
return `No help available for command: ${commandName}`;
}
const usageLines = (Array.isArray(help.usage) ? help.usage : [help.usage]) as string[];
const reasonText = reason ? `Invalid arguments: ${reason}\n\n` : '';
return (
reasonText +
`${help.description}\n\n` +
'Usage:\n' +
usageLines.map((u: string) => ` ${u}`).join('\n') +
'\n\nExamples:\n' +
help.examples.map((e: string) => ` ${e}`).join('\n')
);
}
// Utility for parsing number arguments (handles string/number inputs and quoted strings)
type ParsedNumber =
| { success: true; value: number; }
| { success: false; error: string; };
function parseNumberArg(value: string | number, argName: string = 'argument'): ParsedNumber {
if (typeof value === 'number') {
return { success: true, value };
}
if (typeof value === 'string') {
// Remove leading/trailing single or double quotes
const stripped = value.replace(/^['"]|['"]$/g, '');
const num = parseInt(stripped, 10);
if (isNaN(num)) {
return { success: false, error: `${argName} must be a number, got: "${value}"` };
}
return { success: true, value: num };
}
return { success: false, error: `${argName} must be a string or number, got: ${typeof value}` };
}
/**
* Converts a 1-indexed line number (user-facing) to 0-indexed (LSP internal).
* User sees line 7 in editor → LSP uses line 6.
*
* @param line - 1-indexed line number from user input
* @returns 0-indexed line number for LSP operations
* @example toZeroIndexed(7) // returns 6
*/
function toZeroIndexed(line: number): number {
return line - 1;
}
/**
* Parses and validates a path argument from command arguments.
* Automatically expands environment variables and tilde.
*
* @param args - Array of command arguments
* @param argIndex - Index of the path argument (default: 0)
* @returns Parsed path with expansion applied, or error
*
* @example
* parsePathArg(['~/.config/fish/config.fish'])
* // { success: true, path: '/home/user/.config/fish/config.fish' }
*
* parsePathArg(['$HOME/script.fish'])
* // { success: true, path: '/home/user/script.fish' }
*
* parsePathArg([])
* // { success: false, error: 'Missing path argument' }
*/
type ParsedPath =
| { success: true; path: string; }
| { success: false; error: string; };
function parsePathArg(args: string[], argIndex: number = 0): ParsedPath {
if (argIndex >= args.length) {
return { success: false, error: 'Missing path argument' };
}
const pathArg = args[argIndex];
if (!pathArg || typeof pathArg !== 'string') {
return { success: false, error: 'Path must be a string' };
}
// Expand path immediately (handles ~, $ENV_VARS, etc.)
const expandedPath = SyncFileHelper.expandEnvVars(pathArg);
return { success: true, path: expandedPath };
}
/**
* Parses a pair of numbers from flexible input formats.
* Supports: "7,10" (comma-separated) or "7" "10" (space-separated)
*
* NOTE: This is a reusable utility that can be applied to other commands in the future.
* Consider refactoring other multi-number parameter commands to use this pattern.
*
* @param args - Array of arguments that may contain the number pair
* @param startIndex - Index in args where the pair starts
* @param firstName - Name of first number (for error messages)
* @param secondName - Name of second number (for error messages)
* @returns Parsed number pair or error
*
* @example
* parseNumberPair(['7,10'], 0, 'start', 'end') // { success: true, first: 7, second: 10 }
* parseNumberPair(['7', '10'], 0, 'line', 'char') // { success: true, first: 7, second: 10 }
*/
type ParsedNumberPair =
| { success: true; first: number; second: number; }
| { success: false; error: string; };
function parseNumberPair(
args: (string | number)[],
startIndex: number,
firstName: string = 'first',
secondName: string = 'second',
): ParsedNumberPair {
// Case 1: Comma-separated in single argument - "7,10"
if (startIndex < args.length && typeof args[startIndex] === 'string') {
const arg = args[startIndex] as string;
if (arg.includes(',')) {
const parts = arg.split(',');
if (parts.length !== 2) {
return { success: false, error: `Expected format: "${firstName},${secondName}"` };
}
const [firstStr, secondStr] = parts;
if (!firstStr || !secondStr) {
return { success: false, error: `Missing ${firstName} or ${secondName}` };
}
const firstResult = parseNumberArg(firstStr, firstName);
if (!firstResult.success) {
return { success: false, error: (firstResult as { success: false; error: string; }).error };
}
const secondResult = parseNumberArg(secondStr, secondName);
if (!secondResult.success) {
return { success: false, error: (secondResult as { success: false; error: string; }).error };
}
return { success: true, first: firstResult.value, second: secondResult.value };
}
}
// Case 2: Space-separated in two arguments - "7" "10"
if (startIndex + 1 < args.length) {
const firstArg = args[startIndex];
const secondArg = args[startIndex + 1];
if (firstArg === undefined || secondArg === undefined) {
return { success: false, error: `Missing ${firstName} or ${secondName}` };
}
const firstResult = parseNumberArg(firstArg, firstName);
if (!firstResult.success) {
return { success: false, error: (firstResult as { success: false; error: string; }).error };
}
const secondResult = parseNumberArg(secondArg, secondName);
if (!secondResult.success) {
return { success: false, error: (secondResult as { success: false; error: string; }).error };
}
return { success: true, first: firstResult.value, second: secondResult.value };
}
return { success: false, error: `Expected either "${firstName},${secondName}" or "${firstName}" "${secondName}"` };
}
// Function to create the command handler with dependencies injected
export function createExecuteCommandHandler(
connection: Connection,
) {
const showMessage = (message: string, type: MessageType = MessageType.Info) => {
if (type === MessageType.Info) {
connection.window.showInformationMessage(message);
connection.sendNotification('window/showMessage', {
message: message,
type: MessageType.Info,
});
logger.info(message);
} else {
connection.window.showErrorMessage(message);
connection.sendNotification('window/showMessage', {
message: message,
type: MessageType.Error,
});
logger.error(message);
}
};
// Parse executeRange arguments with flexible position formats
type ParsedExecuteRangeArgs =
| { type: 'valid'; path: string; startLine: number; endLine: number; }
| { type: 'invalid'; reason: string; };
function parseExecuteRangeArgs(args: string[]): ParsedExecuteRangeArgs {
// Need at least 2 args: path + range
if (args.length < 2) {
return { type: 'invalid', reason: 'Missing arguments (need path and line range)' };
}
const [pathArg, ...restArgs] = args;
if (!pathArg) {
return { type: 'invalid', reason: 'Missing path argument' };
}
// Parse the line range starting from index 0 of restArgs
const pairResult = parseNumberPair(restArgs, 0, 'startLine', 'endLine');
if (!pairResult.success) {
return { type: 'invalid', reason: (pairResult as { success: false; error: string; }).error };
}
// TypeScript now knows pairResult.success is true
return {
type: 'valid',
path: pathArg,
startLine: pairResult.first,
endLine: pairResult.second,
};
}
async function executeRange(...args: string[]) {
logger.log('executeRange called with args:', args);
const parsed = parseExecuteRangeArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid executeRange arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.EXECUTE_RANGE, parsed.reason),
MessageType.Error,
);
return;
}
let { path } = parsed;
const { startLine, endLine } = parsed; // Lines are 1-indexed from user
// Expand path (handles ~, $ENV_VARS, etc.)
path = SyncFileHelper.expandEnvVars(path);
// could also do executeLine() on every line in the range
const cached = analyzer.analyzePath(path);
if (!cached) {
showMessage(`File not found or could not be analyzed: ${path}`, MessageType.Error);
return;
}
const { document } = cached;
const current = document;
if (!current) return;
const start = current.getLineStart(toZeroIndexed(startLine));
const end = current.getLineEnd(toZeroIndexed(endLine));
const range = Range.create(start.line, start.character, end.line, end.character);
logger.log('executeRange', current.uri, range);
const text = current.getText(range);
const output = (await execAsync(text)).stdout || '';
logger.log('onExecuteCommand', text);
logger.log('onExecuteCommand', output);
const response = buildExecuteNotificationResponse(text.split('\n').map(s => s.replace(/;\s?$/, '')).join('; '), { stdout: '\n' + output, stderr: '' });
useMessageKind(connection, response);
}
// Parse executeLine arguments
type ParsedExecuteLineArgs =
| { type: 'valid'; path: string; line: number; }
| { type: 'invalid'; reason: string; };
function parseExecuteLineArgs(args: string[]): ParsedExecuteLineArgs {
// Parse path (index 0)
const pathResult = parsePathArg(args, 0);
if (!pathResult.success) {
return { type: 'invalid', reason: (pathResult as { success: false; error: string; }).error };
}
// Parse line number (index 1)
if (args.length < 2) {
return { type: 'invalid', reason: 'Missing line number argument' };
}
const line = args[1];
if (!line) {
return { type: 'invalid', reason: 'Line number must be provided' };
}
const lineResult = parseNumberArg(line, 'line');
if (!lineResult.success) {
return { type: 'invalid', reason: (lineResult as { success: false; error: string; }).error };
}
return { type: 'valid', path: pathResult.path, line: lineResult.value };
}
async function executeLine(...args: string[]) {
logger.log('executeLine called with args:', args);
const parsed = parseExecuteLineArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid executeLine arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.EXECUTE_LINE, parsed.reason),
MessageType.Error,
);
return;
}
const { path, line: lineNumber } = parsed; // Path already expanded by parsePathArg
const cached = analyzer.analyzePath(path);
if (!cached) {
showMessage(`File not found or could not be analyzed: ${path}`, MessageType.Error);
return;
}
const { document } = cached;
logger.log('executeLine', document.uri, lineNumber);
if (!document) return;
const zeroIndexedLine = toZeroIndexed(lineNumber);
const text = document.getLine(zeroIndexedLine);
const cmdOutput = await execAsyncF(`${text}; echo "\\$status: $status"`);
logger.log('executeLine.cmdOutput', cmdOutput);
const output = buildExecuteNotificationResponse(text, { stdout: cmdOutput, stderr: '' });
logger.log('onExecuteCommand', text);
logger.log('onExecuteCommand', output);
// const response = buildExecuteNotificationResponse(text, );
useMessageKind(connection, output);
}
// Parse createTheme arguments
type ParsedCreateThemeArgs =
| { type: 'valid'; path: string; asVariables: boolean; }
| { type: 'invalid'; reason: string; };
function parseCreateThemeArgs(args: string[]): ParsedCreateThemeArgs {
const pathResult = parsePathArg(args, 0);
if (!pathResult.success) {
return { type: 'invalid', reason: (pathResult as { success: false; error: string; }).error };
}
// Optional second argument for asVariables (default: true)
let asVariables = true;
if (args.length >= 2) {
const asVarArg = args[1];
// Accept various boolean representations (all args are strings from LSP)
if (asVarArg === 'false' || asVarArg === '0') {
asVariables = false;
}
}
return { type: 'valid', path: pathResult.path, asVariables };
}
async function createTheme(...args: string[]) {
logger.log('createTheme called with args:', args);
const parsed = parseCreateThemeArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid createTheme arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.CREATE_THEME, parsed.reason),
MessageType.Error,
);
return;
}
const { path, asVariables } = parsed; // Path already expanded by parsePathArg
const cached = analyzer.analyzePath(path);
if (!cached) return;
const { document } = cached;
const output = (await execAsyncFish('fish_config theme dump; or true')).stdout.split('\n');
if (!document) {
logger.error('createTheme', 'Document not found');
connection.sendNotification('window/showMessage', {
message: ` Document not found: ${uriToReadablePath(pathToUri(path))} `,
type: MessageType.Error,
});
return;
}
const outputArr: string[] = [];
// Append the longest line to the file
if (asVariables) {
outputArr.push('\n\n# created by fish-lsp');
}
for (const line of output) {
if (asVariables) {
outputArr.push(`set -gx ${line}`);
} else {
outputArr.push(`${line}`);
}
}
const outputStr = outputArr.join('\n');
const docsEnd = document.positionAt(document.getLines());
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.insert(docsEnd, outputStr),
],
},
};
await connection.workspace.applyEdit(workspaceEdit);
await connection.sendRequest('window/showDocument', {
uri: document.uri,
takeFocus: true,
});
useMessageKind(connection, {
message: `${fishLspPromptIcon} appended theme variables to end of file`,
kind: 'info',
});
}
// Parse executeBuffer arguments
type ParsedExecuteBufferArgs =
| { type: 'valid'; path: string; }
| { type: 'invalid'; reason: string; };
function parseExecuteBufferArgs(args: string[]): ParsedExecuteBufferArgs {
const pathResult = parsePathArg(args, 0);
if (!pathResult.success) {
return { type: 'invalid', reason: (pathResult as { success: false; error: string; }).error };
}
return { type: 'valid', path: pathResult.path };
}
async function executeBuffer(...args: string[]) {
logger.log('executeBuffer called with args:', args);
const parsed = parseExecuteBufferArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid executeBuffer arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.EXECUTE_BUFFER, parsed.reason),
MessageType.Error,
);
return;
}
const { path } = parsed; // Path already expanded by parsePathArg
const output = await execEntireBuffer(path);
// Append the longest line to the file
useMessageKind(connection, output);
}
function handleShowStatusDocs(statusCode?: string | number) {
if (!statusCode) {
logger.log('handleShowStatusDocs', 'No status code provided');
showMessage('No status code provided', MessageType.Error);
return;
}
if (typeof statusCode === 'string' && statusCode.startsWith("'") && statusCode.endsWith("'")) {
statusCode = statusCode.slice(1, -1).toString();
logger.log('handleShowStatusDocs', 'statusCode is string', statusCode);
}
statusCode = Number.parseInt(statusCode.toString()).toString();
const statusInfo = PrebuiltDocumentationMap.getByType('status')
.find(item => item.name === statusCode);
logger.log('handleShowStatusDocs', statusCode, {
foundStatusInfo: PrebuiltDocumentationMap.getByType('status').map(item => item.name),
statusParam: statusCode,
statusInfoFound: statusInfo,
});
if (statusInfo) {
let docMessage = `Status Code: ${statusInfo.name}\n\n`;
const description = statusInfo.description.split(' ');
let lineLen = 0;
for (let i = 0; i < description.length; i++) {
const word = description[i];
if (!word) continue;
if (lineLen + word?.length > 80) {
docMessage += '\n' + word;
lineLen = 0;
continue;
} else if (lineLen === 0) {
docMessage += word;
lineLen += word.length;
} else {
docMessage += ' ' + word;
lineLen += word.length + 1;
}
}
showMessage(docMessage, MessageType.Info);
} else {
showMessage(`No documentation found for status code: ${statusCode}`, MessageType.Error);
}
}
function showWorkspaceMessage() {
const message = `${fishLspPromptIcon} Workspace: ${workspaceManager.current?.name}\n\n Total files analyzed: ${workspaceManager.current?.uris.indexedCount}`;
logger.log('showWorkspaceMessage',
config,
);
showMessage(message, MessageType.Info);
return undefined;
}
// Parse _updateWorkspace arguments
type ParsedUpdateWorkspaceArgs =
| { type: 'valid'; path: string; flags: string[]; }
| { type: 'invalid'; reason: string; };
function parseUpdateWorkspaceArgs(args: string[]): ParsedUpdateWorkspaceArgs {
const pathResult = parsePathArg(args, 0);
if (!pathResult.success) {
return { type: 'invalid', reason: (pathResult as { success: false; error: string; }).error };
}
// Remaining args are flags
const flags = args.slice(1);
return { type: 'valid', path: pathResult.path, flags };
}
async function _updateWorkspace(...args: string[]) {
logger.log('_updateWorkspace called with args:', args);
const parsed = parseUpdateWorkspaceArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid _updateWorkspace arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.UPDATE_WORKSPACE, parsed.reason),
MessageType.Error,
);
return;
}
const { path, flags } = parsed; // Path already expanded by parsePathArg
const silence = flags.includes('--quiet') || flags.includes('-q');
const uri = pathToUri(path);
workspaceManager.handleUpdateDocument(uri);
const message = `${fishLspPromptIcon} Workspace: ${workspaceManager.current?.path}`;
connection.sendNotification('workspace/didChangeWorkspaceFolders', {
event: {
added: [path],
removed: [],
},
});
if (silence) return undefined;
// Using the notification method directly
showMessage(message, MessageType.Info);
return undefined;
}
// Parse fixAllDiagnostics arguments
type ParsedFixAllDiagnosticsArgs =
| { type: 'valid'; path: string; }
| { type: 'invalid'; reason: string; };
function parseFixAllDiagnosticsArgs(args: string[]): ParsedFixAllDiagnosticsArgs {
const pathResult = parsePathArg(args, 0);
if (!pathResult.success) {
return { type: 'invalid', reason: (pathResult as { success: false; error: string; }).error };
}
return { type: 'valid', path: pathResult.path };
}
async function fixAllDiagnostics(...args: string[]) {
logger.log('fixAllDiagnostics called with args:', args);
const parsed = parseFixAllDiagnosticsArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid fixAllDiagnostics arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.FIX_ALL, parsed.reason),
MessageType.Error,
);
return;
}
const { path } = parsed; // Path already expanded by parsePathArg
const uri = pathToUri(path);
logger.log('fixAllDiagnostics', uri);
const cached = analyzer.analyzePath(path);
if (!cached) {
showMessage(`File not found or could not be analyzed: ${path}`, MessageType.Error);
return;
}
const { document } = cached;
const root = analyzer.getRootNode(uri);
if (!document || !root) return;
const diagnostics = root ? await getDiagnosticsAsync(root, document) : [];
logger.warning('fixAllDiagnostics', diagnostics.length, 'diagnostics found');
if (diagnostics.length === 0) {
logger.log('No diagnostics found');
return;
}
const { onCodeActionCallback } = codeActionHandlers();
const actions = await onCodeActionCallback({
textDocument: document.asTextDocumentIdentifier(),
range: getRange(root),
context: {
diagnostics: diagnostics,
},
});
logger.log('fixAllDiagnostics', actions);
const fixAllAction = createFixAllAction(document, actions);
if (!fixAllAction) {
logger.log('fixAllDiagnostics did not find any fixAll actions');
return;
}
const fixCount = fixAllAction?.data.totalEdits || 0;
if (fixCount > 0) {
logger.log('fixAllDiagnostics', `Can apply ${fixCount} fixes`);
const result = await connection.window.showInformationMessage(
`Fix all ${fixAllAction.data.totalEdits} diagnostics on ${uriToReadablePath(uri)}`,
{ title: 'Yes' },
{ title: 'Cancel' },
);
const { title } = result?.title ? result : { title: 'Cancel' };
if (title === 'Cancel') {
connection.sendNotification('window/showMessage', {
type: MessageType.Info, // Info, Warning, Error, Log
message: ' No changes were made to the file. ',
});
return;
}
// Apply all edits
const workspaceEdit = fixAllAction.edit;
if (!workspaceEdit) return;
await connection.workspace.applyEdit(workspaceEdit);
connection.sendNotification('window/showMessage', {
type: MessageType.Info, // Info, Warning, Error, Log
message: ` Applied ${fixCount} quick fixes `,
});
}
}
function toggleSingleWorkspaceSupport() {
const currentConfig = config.fish_lsp_single_workspace_support;
config.fish_lsp_single_workspace_support = !currentConfig;
connection.sendNotification('window/showMessage', {
type: MessageType.Info, // Info, Warning, Error, Log
message: ` Single workspace support: ${config.fish_lsp_single_workspace_support ? 'ENABLED' : 'DISABLED'} `,
});
}
// Parse outputFishLspEnv arguments
type ParsedOutputFishLspEnvArgs =
| { type: 'valid'; path: string; }
| { type: 'invalid'; reason: string; };
function parseOutputFishLspEnvArgs(args: string[]): ParsedOutputFishLspEnvArgs {
const pathResult = parsePathArg(args, 0);
if (!pathResult.success) {
return { type: 'invalid', reason: (pathResult as { success: false; error: string; }).error };
}
return { type: 'valid', path: pathResult.path };
}
const envOutputOptions = {
confd: false,
comments: true,
global: true,
local: false,
export: true,
json: false,
only: undefined,
};
function outputFishLspEnv(...args: string[]) {
logger.log('outputFishLspEnv called with args:', args);
const parsed = parseOutputFishLspEnvArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid outputFishLspEnv arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.GENERATE_ENV_VARIABLES, parsed.reason),
MessageType.Error,
);
return;
}
const { path } = parsed; // Path already expanded by parsePathArg
const cached = analyzer.analyzePath(path);
if (!cached) return;
const { document } = cached;
if (!document) return;
const output: string[] = ['\n'];
const outputCallback = (s: string) => {
output.push(s);
};
handleEnvOutput('show', outputCallback, envOutputOptions);
showMessage(`${fishLspPromptIcon} Appending fish-lsp environment variables to the end of the file`, MessageType.Info);
const docsEnd = document.positionAt(document.getLines());
const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [
TextEdit.insert(docsEnd, output.join('\n')),
],
},
};
connection.workspace.applyEdit(workspaceEdit);
}
type ParsedShowReferencesArgs =
| { type: 'symbol'; name: string; }
| { type: 'location'; path: string; line: number; char: number; }
| { type: 'invalid'; reason: string; };
function parseShowReferencesArgs(args: string[]): ParsedShowReferencesArgs {
// Case 1: Single argument - could be symbol name only
if (args.length === 1) {
const [arg] = args;
if (!arg) {
return { type: 'invalid', reason: 'Missing argument' };
}
// Check if this looks like a path (contains /, ~, or $ENV_VAR)
// OR if it can be expanded to a different value (meaning it has expandable components)
const isPathLike = arg.includes('/') || arg.startsWith('~') || arg.includes('$');
const canExpand = SyncFileHelper.isExpandable(arg);
if (isPathLike || canExpand) {
return { type: 'invalid', reason: 'Path provided without line/character position' };
}
return { type: 'symbol', name: arg };
}
// Case 2 & 3: Path with position - use parseNumberPair for flexibility
if (args.length >= 2) {
const [pathArg, ...positionArgs] = args;
if (!pathArg) {
return { type: 'invalid', reason: 'Missing path argument' };
}
// Use the generic parseNumberPair utility to handle both "line,char" and "line" "char"
const pairResult = parseNumberPair(positionArgs, 0, 'line', 'character');
if (!pairResult.success) {
return { type: 'invalid', reason: (pairResult as { success: false; error: string; }).error };
}
// TypeScript now knows pairResult.success is true
return {
type: 'location',
path: pathArg,
line: pairResult.first,
char: pairResult.second,
};
}
return { type: 'invalid', reason: 'No arguments provided' };
}
async function showReferences(...args: string[]) {
logger.log('showReferences called with args:', args);
const parsed = parseShowReferencesArgs(args);
if (parsed.type === 'invalid') {
logger.warning('Invalid showReferences arguments:', { args, reason: parsed.reason });
showMessage(
formatCommandHelp(CommandNames.SHOW_REFERENCES, parsed.reason),
MessageType.Error,
);
return [];
}
let uri: string;
let position: Position;
if (parsed.type === 'symbol') {
logger.log('Searching for global symbol:', parsed.name);
const globalSymbol = analyzer.globalSymbols.findFirst(parsed.name);
if (!globalSymbol) {
showMessage(`No global symbol found with name: ${parsed.name}`, MessageType.Error);
return [];
}
logger.log('Found global symbol:', {
name: globalSymbol.name,
uri: globalSymbol.uri,
range: globalSymbol.range,
});
uri = globalSymbol.uri;
position = globalSymbol.toPosition();
} else if (parsed.type === 'location') {
// Use SyncFileHelper to properly expand path (handles ~, $ENV_VARS, etc.)
const expandedPath = SyncFileHelper.expandEnvVars(parsed.path);
// Numbers are already parsed and validated by parseShowReferencesArgs
// Convert 1-indexed (user-facing) line numbers to 0-indexed (LSP) positions
uri = pathToUri(expandedPath);
position = Position.create(toZeroIndexed(parsed.line), parsed.char);
} else {
return [];
}
logger.log('showReferences', { uri, position });
// Call server.onReferences() directly to get references
const references = await FishServer.instance.onReferences({
textDocument: { uri },
position: position,
context: {
includeDeclaration: true,
},
});
logger.log('showReferences result', {
count: references.length,
references: references.map(loc => ({
uri: loc.uri,
range: loc.range,
})),
});
if (references.length === 0) {
showMessage(
`No references found at ${uriToReadablePath(uri)}:${position.line + 1}:${position.character + 1}`,
MessageType.Info,
);
return references;
} else {
// Format references as a readable message
const refMessage = references.map((loc, idx) => {
const locPath = uriToReadablePath(loc.uri);
const line = loc.range.start.line + 1;
const char = loc.range.start.character + 1;
return ` [${idx + 1}] ${locPath}:${line}:${char}`;
}).join('\n');
const message = `Found ${references.length} reference(s):\n${refMessage}`;
showMessage(message, MessageType.Info);
}
// Group references by URI to find the first reference in each document
const referencesByUri = new Map();
for (const ref of references) {
const existing = referencesByUri.get(ref.uri) || [];
existing.push(ref);
referencesByUri.set(ref.uri, existing);
}
// Navigate to the first reference in each document
for (const [refUri, refs] of referencesByUri.entries()) {
// Ensure document is un-opened
if (documents.get(uriToPath(refUri)) || uri === refUri) {
logger.log(`Document already open, skipping: ${uriToReadablePath(refUri)}`);
continue;
}
// Verify the document exists before trying to open it
const refPath = uriToPath(refUri);
const refDoc = documents.get(refPath) || analyzer.analyzePath(refPath)?.document;
if (!refDoc) {
logger.warning(`Skipping non-existent document: ${uriToReadablePath(refUri)}`);
continue;
}
// Sort references by line number to get the first one in the document
const sortedRefs = refs.sort((a, b) => {
if (a.range.start.line !== b.range.start.line) {
return a.range.start.line - b.range.start.line;
}
return a.range.start.character - b.range.start.character;
});
const firstRef = sortedRefs[0];
if (!firstRef) continue;
if (workspaceManager.current?.getUris().includes(refUri) === false) {
logger.log(`Reference URI not in current workspace, skipping: ${uriToReadablePath(refUri)}`);
continue;
}
// Use window/showDocument to open and navigate to the first reference
try {
await connection.sendRequest('window/showDocument', {
uri: refUri,
takeFocus: false, // Don't steal focus from current document
selection: firstRef?.range, // Highlight the first reference
});
logger.log(`Opened ${uriToReadablePath(refUri)} at line ${firstRef!.range.start.line + 1}`);
} catch (error) {
logger.error(`Failed to show document ${refUri}:`, error);
}
}
return references;
}
function showEnvVariables(...opts: string[]) {
if (!opts.some(o => ['all', 'changed', 'default', 'unchanged'].includes(o))) {
opts = ['all', ...opts];
}
const mode = opts[0] || 'all';
const noComments: boolean = opts.find(o => o === '--no-comments') ? true : false;
const noValues: boolean = opts.find(o => o === '--no-values') ? true : false;
const asJson: boolean = opts.find(o => o === '--json') ? true : false;
let variables = PrebuiltDocumentationMap
.getByType('variable', 'fishlsp')
.filter((v) => EnvVariableJson.is(v) ? !v.isDeprecated : false)
.map(v => v as EnvVariableJson);
const allVars = variables;
const changedVars = variables.filter(v => env.has(v.name));
const unchangedVars = allVars.filter(v => !changedVars.map(c => c.name).includes(v.name));
const defaultVars = variables.filter(v => {
const defConfig = getDefaultConfiguration();
return v.name in defConfig;
});
const defaultConfig = getDefaultConfiguration();
let resVars: EnvVariableJson[] = [];
if (mode === 'all') {
resVars = allVars;
} else if (mode === 'changed') {
resVars = variables.filter(v => env.has(v.name));
} else if (mode === 'unchanged') {
resVars = variables.filter(v => !changedVars.map(c => c.name).includes(v.name));
} else if (mode === 'default') {
variables = Object.entries(getDefaultConfiguration()).map(([key, _]) => {
const EnvVar = variables.find(v => v.name === key);
if (EnvVar) return EnvVar;
}).filter((v): v is EnvVariableJson => v !== undefined);
resVars = variables.filter((v): v is EnvVariableJson => v !== undefined);
}
const logArr = (resVars: EnvVariableJson[]) => ({
names: resVars.map(v => v.name),
len: resVars.length,
});
logger.log('showEnvVariables', {
totalVariables: variables.length,
all: logArr(allVars),
changedVariables: logArr(changedVars),
unchangedVariables: logArr(unchangedVars),
defaultVariables: logArr(defaultVars),
});
if (asJson) {
const results: Record = {} as Record;
resVars.forEach(v => {
const { name } = v as { name: Config.ConfigKeyType; };
if (!name || !(name in config)) return;
if (mode === 'default') results[name] = defaultConfig[name];
else results[name] = config[name];
});
showMessage(
[
'\n{',
Object.entries(results).map(([key, value]) => {
const k = JSON.stringify(key);
const v = JSON.stringify(value).replaceAll('\n', ' ').trim() + ',';
return ` ${k}: ${v}`;
}).join('\n'),
'}',
].join('\n'),
MessageType.Info,
);
return;
}
const filteredAllVars = (vals: EnvVariableJson[]) => {
const res = vals.map(v => {
const value = noValues ? '' : EnvVariableTransformers.convertValueToShellOutput(config[v.name as Config.ConfigKeyType]);
const comment = noComments ? '' : `# ${v.description.replace(/\n/g, ' ')}\n`;
if (noValues && noComments) return `${v.name}`;
return `${comment}set ${v.name} ${value}\n`;
});
return res.join('\n');
};
let message = '\n';
if (mode === 'all' || !mode) {
message += filteredAllVars(allVars);
} else if (mode === 'changed') {
message += filteredAllVars(changedVars);
} else if (mode === 'unchanged') {
message += filteredAllVars(unchangedVars);
} else if (mode === 'default') {
message += filteredAllVars(defaultVars);
}
showMessage(message.trimEnd(), MessageType.Info);
}
function showInfo() {
const message = JSON.stringify({
version: PkgJson.version,
buildTime: PkgJson.buildTime,
repo: PkgJson.path,
}, null, 2);
showMessage(message, MessageType.Info);
}
// Command handler mapping
const commandHandlers: Record Promise | void | Promise | Promise> = {
[CommandNames.EXECUTE_RANGE]: executeRange,
[CommandNames.EXECUTE_LINE]: executeLine,
[CommandNames.EXECUTE_BUFFER]: executeBuffer,
[CommandNames.EXECUTE]: executeBuffer,
[CommandNames.CREATE_THEME]: createTheme,
[CommandNames.SHOW_STATUS_DOCS]: handleShowStatusDocs,
[CommandNames.SHOW_WORKSPACE_MESSAGE]: showWorkspaceMessage,
[CommandNames.UPDATE_WORKSPACE]: _updateWorkspace,
[CommandNames.FIX_ALL]: fixAllDiagnostics,
[CommandNames.TOGGLE_SINGLE_WORKSPACE_SUPPORT]: toggleSingleWorkspaceSupport,
[CommandNames.GENERATE_ENV_VARIABLES]: outputFishLspEnv,
[CommandNames.SHOW_ENV_VARIABLES]: showEnvVariables,
[CommandNames.SHOW_REFERENCES]: showReferences,
[CommandNames.SHOW_INFO]: showInfo,
};
// Main command handler function
return async function onExecuteCommand(params: ExecuteCommandParams): Promise {
logger.log('onExecuteCommand', params);
const handler = commandHandlers[params.command];
if (!handler) {
logger.log(`Unknown command: ${params.command}`);
return;
}
await handler(...params.arguments || []);
};
}
================================================
FILE: src/config.ts
================================================
import { z } from 'zod';
import { Connection, FormattingOptions, InitializeParams, InitializeResult, TextDocumentSyncKind } from 'vscode-languageserver';
import { createServerLogger, logger } from './logger';
import { PrebuiltDocumentationMap, EnvVariableJson } from './utils/snippets';
import { AllSupportedActions } from './code-actions/action-kinds';
import { LspCommands } from './command';
import { getBuildTimeJsonObj, PackageVersion, SubcommandEnv } from './utils/commander-cli-subcommands';
import { ErrorCodes } from './diagnostics/error-codes';
import { FishSemanticTokens } from './utils/semantics';
import { getProjectRootPath } from './utils/path-resolution';
/********************************************
********** Handlers/Providers ***********
*******************************************/
export const ConfigHandlerSchema = z.object({
complete: z.boolean().default(true),
hover: z.boolean().default(true),
rename: z.boolean().default(true),
definition: z.boolean().default(true),
implementation: z.boolean().default(true),
reference: z.boolean().default(true),
logger: z.boolean().default(true),
formatting: z.boolean().default(true),
formatRange: z.boolean().default(true),
typeFormatting: z.boolean().default(true),
codeAction: z.boolean().default(true),
codeLens: z.boolean().default(true),
folding: z.boolean().default(true),
selectionRange: z.boolean().default(true),
signature: z.boolean().default(true),
executeCommand: z.boolean().default(true),
inlayHint: z.boolean().default(true),
highlight: z.boolean().default(true),
diagnostic: z.boolean().default(true),
popups: z.boolean().default(true),
semanticTokens: z.boolean().default(true),
});
/**
* The configHandlers object stores the enabled/disabled state of the cli flags
* for the language server handlers.
*
* The object (shaped by `ConfigHandlerSchema`) contains a single key and value pair
* for each handler type that is supported by the language server. Each handler
* can only either be enabled or disabled, and their default value is `true`.
*
* The object could be checked three different times during the initialization of the
* language server:
*
* 1.) The `initializeParams` are passed into the language server during startup
* - `initializeParams.fish_lsp_enabled_handlers`
* - `initializeParams.fish_lsp_disabled_handlers`
* 2.) This object parses the shell env values found in the variables:
* - `fish_lsp_enabled_handlers`
* - `fish_lsp_disabled_handlers`
*
* 3.) Next, it uses the cli flags parsed from the `--enable` and `--disable` flags:
* - keys are from the validHandlers array.
*
* Finally, its values can be used to determine if a handler is enabled or disabled.
*
* For example, `configHandlers.complete` will store the state of the `complete` handler.
*/
export const configHandlers = ConfigHandlerSchema.parse({});
export const validHandlers: Array = [
'complete', 'hover', 'rename', 'definition', 'implementation', 'reference', 'formatting',
'formatRange', 'typeFormatting', 'codeAction', 'codeLens', 'folding', 'signature',
'executeCommand', 'inlayHint', 'highlight', 'diagnostic', 'popups', 'semanticTokens',
];
export function updateHandlers(keys: string[], value: boolean): void {
keys.forEach(key => {
if (validHandlers.includes(key as keyof typeof ConfigHandlerSchema.shape)) {
configHandlers[key as keyof typeof ConfigHandlerSchema.shape] = value;
}
});
Config.fixEnabledDisabledHandlers();
}
/********************************************
********** User Env ***********
*******************************************/
export const ConfigSchema = z.object({
/** Handlers that are enabled in the language server */
fish_lsp_enabled_handlers: z.array(z.string()).default([]),
/** Handlers that are disabled in the language server */
fish_lsp_disabled_handlers: z.array(z.string()).default([]),
/** Characters that completion items will be accepted on */
fish_lsp_commit_characters: z.array(z.string()).default(['\t', ';', ' ']),
/** Path to the log files */
fish_lsp_log_file: z.string().default(''),
/** show startup analysis notification */
fish_lsp_log_level: z.string().default(''),
/** All workspaces/paths for the language-server to index */
fish_lsp_all_indexed_paths: z.array(z.string()).default(['$__fish_config_dir', '$__fish_data_dir']),
/** All workspace/paths that the language-server should be able to rename inside*/
fish_lsp_modifiable_paths: z.array(z.string()).default(['$__fish_config_dir']),
/** error code numbers to disable */
fish_lsp_diagnostic_disable_error_codes: z.array(z.number()).default([]),
/** max number of diagnostics */
fish_lsp_max_diagnostics: z.number().default(0),
/** fish lsp experimental diagnostics */
fish_lsp_enable_experimental_diagnostics: z.boolean().default(false),
/** diagnostic 3002 warnings should be shown forcing the user to check if a command exists before using it */
fish_lsp_strict_conditional_command_warnings: z.boolean().default(false),
/**
* include diagnostic warnings when an external shell command is used instead of
* a fish built-in command
*/
fish_lsp_prefer_builtin_fish_commands: z.boolean().default(false),
/**
* don't warn usage of fish wrapper functions
*/
fish_lsp_allow_fish_wrapper_functions: z.boolean().default(true),
/**
* require autoloaded functions to have a description in their header
*/
fish_lsp_require_autoloaded_functions_to_have_description: z.boolean().default(true),
/** max background files */
fish_lsp_max_background_files: z.number().default(10000),
/** show startup analysis notification */
fish_lsp_show_client_popups: z.boolean().default(true),
/** single workspace support */
fish_lsp_single_workspace_support: z.boolean().default(false),
/** paths to ignore when searching for workspace folders */
fish_lsp_ignore_paths: z.array(z.string()).default(['**/.git/**', '**/node_modules/**', '**/containerized/**', '**/docker/**']),
/** max depth to search for workspace folders */
fish_lsp_max_workspace_depth: z.number().default(3),
/** path to fish executable for child processes */
fish_lsp_fish_path: z.string().default('fish'),
});
export type Config = z.infer;
export function getConfigFromEnvironmentVariables(): {
config: Config;
environmentVariablesUsed: string[];
} {
const rawConfig = {
fish_lsp_enabled_handlers: process.env.fish_lsp_enabled_handlers?.split(' '),
fish_lsp_disabled_handlers: process.env.fish_lsp_disabled_handlers?.split(' '),
fish_lsp_commit_characters: process.env.fish_lsp_commit_characters?.split(' '),
fish_lsp_log_file: process.env.fish_lsp_log_file || process.env.fish_lsp_logfile,
fish_lsp_log_level: process.env.fish_lsp_log_level,
fish_lsp_all_indexed_paths: process.env.fish_lsp_all_indexed_paths?.split(' '),
fish_lsp_modifiable_paths: process.env.fish_lsp_modifiable_paths?.split(' '),
fish_lsp_diagnostic_disable_error_codes: process.env.fish_lsp_diagnostic_disable_error_codes?.split(' ').map(toNumber).filter(n => !!n),
fish_lsp_max_diagnostics: toNumber(process.env.fish_lsp_max_diagnostics || '10'),
fish_lsp_enable_experimental_diagnostics: toBoolean(process.env.fish_lsp_enable_experimental_diagnostics),
fish_lsp_prefer_builtin_fish_commands: toBoolean(process.env.fish_lsp_prefer_builtin_fish_commands),
fish_lsp_strict_conditional_command_warnings: toBoolean(process.env.fish_lsp_strict_conditional_command_warnings),
fish_lsp_allow_fish_wrapper_functions: toBoolean(process.env.fish_lsp_allow_fish_wrapper_functions),
fish_lsp_require_autoloaded_functions_to_have_description: toBoolean(process.env.fish_lsp_require_autoloaded_functions_to_have_description),
fish_lsp_max_background_files: toNumber(process.env.fish_lsp_max_background_files || '10000'),
fish_lsp_show_client_popups: toBoolean(process.env.fish_lsp_show_client_popups),
fish_lsp_single_workspace_support: toBoolean(process.env.fish_lsp_single_workspace_support),
fish_lsp_ignore_paths: process.env.fish_lsp_ignore_paths?.split(' '),
fish_lsp_max_workspace_depth: toNumber(process.env.fish_lsp_max_workspace_depth || '4'),
fish_lsp_fish_path: process.env.fish_lsp_fish_path,
};
const environmentVariablesUsed = Object.entries(rawConfig)
.map(([key, value]) => typeof value !== 'undefined' ? key : null)
.filter((key): key is string => key !== null);
const config = ConfigSchema.parse(rawConfig);
if (config.fish_lsp_allow_fish_wrapper_functions) {
config.fish_lsp_diagnostic_disable_error_codes.push(ErrorCodes.usedWrapperFunction);
}
if (config.fish_lsp_strict_conditional_command_warnings) {
config.fish_lsp_diagnostic_disable_error_codes.push(ErrorCodes.missingQuietOption);
}
if (config.fish_lsp_require_autoloaded_functions_to_have_description) {
config.fish_lsp_diagnostic_disable_error_codes.push(ErrorCodes.requireAutloadedFunctionHasDescription);
}
return { config, environmentVariablesUsed };
}
export function getDefaultConfiguration(): Config {
return ConfigSchema.parse({});
}
/**
* convert boolean & number shell strings to their correct type
*/
export const toBoolean = (s?: string): boolean | undefined =>
typeof s !== 'undefined' ? s === 'true' || s === '1' : undefined;
export const toNumber = (s?: string | number): number | undefined =>
typeof s === 'undefined'
? undefined
: typeof s === 'number' ? s
: typeof s === 'string' ? parseInt(s, 10) : parseInt(String(s), 10) || undefined;
function buildOutput(confd: boolean, result: string[]) {
// there has to be a `env` index
const envIndex = process.argv.findIndex(s => s === 'env');
// get the cli command used to generate the env output,
// which will be included in the comments of the output if `confd` is true
const command = ['fish-lsp', ...process.argv.slice(envIndex)].join(' ').trimEnd();
// only show built by line if confd, otherwise show result only
return confd
? [
`# built by \`${command}\``,
'type -aq fish-lsp || exit',
'if status is-interactive',
result.map(line =>
line.split('\n').map(innerLine => ' ' + innerLine).join('\n').trimEnd(),
).join('\n\n').trimEnd(),
'end',
].join('\n')
: result.join('\n').trimEnd();
}
/**
* Transforms a fish-lsp env variable value from shell string to array
*/
export namespace EnvVariableTransformers {
/**
* convertValueToShellOutput - Converts a value to valid fish-shell code
* @param {Config.ConfigValueType} value - the value to convert
* @returns string - the converted value
*/
export function convertValueToShellOutput(value: Config.ConfigValueType) {
if (!Array.isArray(value)) return escapeValue(value) + '\n';
// For arrays
if (value.length === 0) return '\n'; // empty array -> ''
return value.map(v => escapeValue(v)).join(' ') + '\n'; // escape and join array
}
export function getDefaultValueAsShellOutput(
key: Config.ConfigKeyType,
opts: { json: boolean; } = { json: false },
) {
const value = Config.getDefaultValue(key);
if (opts.json) {
return JSON.stringify(value, null, 2);
}
return convertValueToShellOutput(value);
}
export function getEnvVariableJsonObject(
result: { [k in Config.ConfigKeyType]: Config.ConfigValueType },
key: Config.ConfigKeyType,
value?: Config.ConfigValueType,
) {
result[key] = value ?? config[key];
return result;
}
}
/**
* Handles building the output for the `fish-lsp env` command
*/
export function handleEnvOutput(
outputType: 'show' | 'create' | 'showDefault',
callbackfn: (str: string) => void = (str) => logger.logToStdout(str),
opts: {
only: string[] | undefined;
confd: boolean;
comments: boolean;
global: boolean;
local: boolean;
export: boolean;
json: boolean;
} = SubcommandEnv.defaultHandlerOptions,
) {
const command = getEnvVariableCommand(opts.global, opts.local, opts.export);
const result: string[] = [];
const variables = PrebuiltDocumentationMap
.getByType('variable', 'fishlsp')
.filter((v) => EnvVariableJson.is(v) ? !v.isDeprecated : false)
.map(v => v as EnvVariableJson);
const getEnvVariableJsonObject = (keyName: string): EnvVariableJson =>
variables.find(entry => entry.name === keyName)!;
// Converts a value to valid fish-shell code
const convertValueToShellOutput = (value: Config.ConfigValueType) => {
if (!Array.isArray(value)) return escapeValue(value) + '\n';
// For arrays
if (value.length === 0) return '\n'; // empty array -> ''
return value.map(v => escapeValue(v)).join(' ') + '\n'; // escape and join array
};
// Gets the default value for an environment variable, from the zod schema
const getDefaultValueAsShellOutput = (key: Config.ConfigKeyType) => {
const value = Config.getDefaultValue(key);
if (opts.json) {
return JSON.stringify(value, null, 2);
}
return convertValueToShellOutput(value);
};
// Builds the line (with its comment if needed) for a fish_lsp_* variable.
// Does not include the value
const buildBasicLine = (
entry: EnvVariableJson,
command: EnvVariableCommand,
key: Config.ConfigKeyType,
) => {
if (!opts.comments) return `${command} ${key} `;
return [
EnvVariableJson.toCliOutput(entry),
`${command} ${key} `,
].join('\n');
};
// builds the output for a fish_lsp_* variable (including the comments, and valid shell code)
const buildOutputSection = (
entry: EnvVariableJson,
command: EnvVariableCommand,
key: Config.ConfigKeyType,
value: Config.ConfigValueType,
) => {
let line = buildBasicLine(entry, command, key);
switch (outputType) {
case 'show':
line += convertValueToShellOutput(value);
break;
case 'showDefault':
line += getDefaultValueAsShellOutput(key);
break;
case 'create':
default:
line += '\n';
break;
}
return line;
};
if (opts.json) {
const jsonOutput: Record = {};
for (const item of Object.entries(config)) {
const [key, value] = item;
if (opts.only && !opts.only.includes(key)) continue;
switch (outputType) {
case 'create':
jsonOutput[key] = config[key as keyof Config];
continue;
case 'showDefault':
jsonOutput[key] = Config.getDefaultValue(key as keyof Config);
continue;
case 'show':
default:
jsonOutput[key] = value;
continue;
}
}
callbackfn(JSON.stringify(jsonOutput, null, 2));
return JSON.stringify(jsonOutput, null, 2);
}
// show - output what is currently being used
// create - output the default value
// showDefault - output the default value
for (const item of Object.entries(config)) {
const [key, value] = item;
if (opts.only && !opts.only.includes(key)) continue;
const configKey = key as keyof Config;
const entry = getEnvVariableJsonObject(key);
const line = buildOutputSection(entry, command, configKey, value);
result.push(line);
}
const output = buildOutput(opts.confd, result);
callbackfn(output);
return output;
}
/*************************************
******* formatting helpers ********
************************************/
function escapeValue(value: string | number | boolean): string {
if (typeof value === 'string') {
// for config values that are variables, surround w/ -> "
if (value.startsWith('$__fish')) return `"${value}"`;
if (value === '') return "''"; // empty string -> ''
// Replace special characters with their escaped equivalents
return `'${value.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/'/g, "\\'")}'`;
} else {
// Return non-string types as they are
return value.toString();
}
}
type EnvVariableCommand = 'set -g' | 'set -l' | 'set -gx' | 'set -lx' | 'set' | 'set -x';
/**
* getEnvVariableCommand - returns the correct command for setting environment variables
* in fish-shell. Used for generating `fish-lsp env` output. Result string will be
* either `set -g`, `set -l`, `set -gx`, or `set -lx`, depending on the flags passed.
* ___
* ```fish
* >_ fish-lsp env --no-global --no-export --no-comments | head -n 1
* set -l fish_lsp_enabled_handlers
* ```
* ___
* @param {boolean} useGlobal - whether to use the global flag
* @param {boolean} useLocal - allows for skipping the local flag
* @param {boolean} useExport - whether to use the export flag
* @returns {string} - the correct command for setting environment variables
*/
function getEnvVariableCommand(useGlobal: boolean, useLocal: boolean, useExport: boolean): EnvVariableCommand {
let command = 'set';
command = useGlobal ? `${command} -g` : useLocal ? `${command} -l` : command;
command = useExport ? command.endsWith('-g') || command.endsWith('-l') ? `${command}x` : `${command} -x` : command;
return command as 'set -g' | 'set -l' | 'set -gx' | 'set -lx' | 'set' | 'set -x';
}
export const FormatOptions: FormattingOptions = {
insertSpaces: true,
tabSize: 4,
};
/********************************************
*** Config ***
*******************************************/
export namespace Config {
// eslint-disable-next-line prefer-const
export let isWebServer = false;
/**
* fixPopups - updates the `config.fish_lsp_show_client_popups` value based on the 3 cases:
* - cli flags include 'popups' -> directly sets `fish_lsp_show_client_popups`
* - `config.fish_lsp_enabled_handlers`/`config.fish_lsp_disabled_handlers` includes 'popups'
* - if both set && env doesn't set popups -> disable popups
* - if enabled && env doesn't set popups-> enable popups
* - if disabled && env doesn't set popups -> disable popups
* - if env sets popups -> use env for popups && don't override with handler
* - `config.fish_lsp_show_client_popups` is set in the environment variables
* @param {string[]} enabled - the cli flags that are enabled
* @param {string[]} disabled - the cli flags that are disabled
* @returns {void}
*/
export function fixPopups(enabled: string[], disabled: string[]): void {
/*
* `enabled/disabled` cli flag arrays are used instead of `configHandlers`
* because `configHandlers` always sets `popups` to true
*/
if (enabled.includes('popups') || disabled.includes('popups')) {
if (enabled.includes('popups')) config.fish_lsp_show_client_popups = true;
if (disabled.includes('popups')) config.fish_lsp_show_client_popups = false;
return;
}
/**
* `configHandlers.popups` is set to false, so popups are disabled
*/
if (configHandlers.popups === false) {
config.fish_lsp_show_client_popups = false;
return;
}
// envValue is the value of `process.env.fish_lsp_show_client_popups`
const envValue = toBoolean(process.env.fish_lsp_show_client_popups);
// check error case where both are set
if (
config.fish_lsp_enabled_handlers.includes('popups')
&& config.fish_lsp_disabled_handlers.includes('popups')
) {
if (envValue) {
config.fish_lsp_show_client_popups = envValue;
return;
} else {
config.fish_lsp_show_client_popups = false;
return;
}
}
/**
* `process.env.fish_lsp_show_client_popups` is not set, and
* `fish_lsp_enabled_handlers/fish_lsp_disabled_handlers` includes 'popups'
*/
if (typeof envValue === 'undefined') {
if (config.fish_lsp_enabled_handlers.includes('popups')) {
config.fish_lsp_show_client_popups = true;
return;
}
/** config.fish_lsp_disabled_handlers is from the fish env */
if (config.fish_lsp_disabled_handlers.includes('popups')) {
config.fish_lsp_show_client_popups = false;
return;
}
}
// `process.env.fish_lsp_show_client_popups` is set and 'popups' is enabled/disabled in the handlers
return;
}
export type ConfigValueType = string | number | boolean | string[] | number[]; // Config[keyof Config] | string[] | number[];
export type ConfigKeyType = keyof Config;
export function getDefaultValue(key: keyof Config): Config[keyof Config] {
const defaults = ConfigSchema.parse({});
return defaults[key];
}
export function getDocsForKey(key: keyof Config): string {
const entry = PrebuiltDocumentationMap.getByType('variable', 'fishlsp').find(e => e.name === key);
if (entry) {
return entry.description;
}
return '';
}
/**
* Builder for the `envDocs` object
*/
const getDocsObj = (): Record => {
const docsObj = {} as Record;
const entries = PrebuiltDocumentationMap.getByType('variable', 'fishlsp');
entries.forEach(entry => {
if (EnvVariableJson.is(entry)) {
if (entry?.isDeprecated) return;
docsObj[entry.name as keyof Config] = entry.shortDescription;
}
});
return docsObj;
};
/**
* Config.docs[fish_lsp_*]: Documentation for fish_lsp_* variables
* Used for the `fish-lsp env` cli completions
*/
export const envDocs: Record = getDocsObj();
export const allServerFeatures = Array.from([...validHandlers]);
/**
* All old environment variables mapped to their new key names.
*/
export const deprecatedKeys: { [deprecated_key: string]: keyof Config; } = {
['fish_lsp_logfile']: 'fish_lsp_log_file',
};
export function isDeprecatedKey(key: string): boolean {
if (key.trim() === '') return false;
return Object.keys(deprecatedKeys).includes(key);
}
// Or use a helper function approach for even better typing
export const allKeys: Array = Object.keys(ConfigSchema.parse({})) as Array;
/**
* We only need to call this for the `initializationOptions`, but it ensures any string
* passed in is a valid config key. If the key is not found, it will return undefined.
*
* @param {string} key - the key to check
* @return {keyof Config | undefined} - the key if it exists in the config, or undefined
*/
export function getEnvVariableKey(key: string): keyof Config | undefined {
if (key in config) {
return key as keyof Config;
}
if (Object.keys(deprecatedKeys).includes(key)) {
return deprecatedKeys[key] as keyof Config;
}
return undefined;
}
/**
* update the `config` object from the `params.initializationOptions` object,
* where the `params` are `InitializeParams` from the language client.
* @param {Config | null} initializationOptions - the initialization options from the client
* @returns {void} updates both the `config` and `configHandlers` objects
*/
export function updateFromInitializationOptions(initializationOptions: Config | null): void {
if (!initializationOptions) return;
ConfigSchema.parse(initializationOptions);
Object.keys(initializationOptions).forEach((key) => {
const configKey = getEnvVariableKey(key);
if (!configKey) return;
(config[configKey] as any) = initializationOptions[configKey];
});
if (initializationOptions.fish_lsp_enabled_handlers) {
updateHandlers(initializationOptions.fish_lsp_enabled_handlers, true);
}
if (initializationOptions.fish_lsp_disabled_handlers) {
updateHandlers(initializationOptions.fish_lsp_disabled_handlers, false);
}
}
/**
* Call this after updating the `configHandlers` to ensure that all
* enabled/disabled handlers are set correctly.
*/
export function fixEnabledDisabledHandlers(): void {
config.fish_lsp_enabled_handlers = [];
config.fish_lsp_disabled_handlers = [];
Object.keys(configHandlers).forEach((key) => {
const value = configHandlers[key as keyof typeof ConfigHandlerSchema.shape];
if (!value) {
config.fish_lsp_disabled_handlers.push(key);
} else {
config.fish_lsp_enabled_handlers.push(key);
}
});
}
/**
* getResultCapabilities - returns the capabilities for the language server based on the
* Uses both global objects: `config` and `configHandlers`
* Therefore, these values must be set/updated before calling this function.
*/
export function getResultCapabilities(): InitializeResult {
// Extend the serverInfo object with additional information
const serverInfo = {
name: 'fish-lsp',
version: PackageVersion,
buildTime: getBuildTimeJsonObj()?.timestamp,
buildPath: getProjectRootPath(),
} as InitializeResult['serverInfo'];
return {
capabilities: {
textDocumentSync: {
openClose: true,
change: TextDocumentSyncKind.Incremental,
save: { includeText: true },
},
completionProvider: configHandlers.complete ? {
resolveProvider: true,
allCommitCharacters: config.fish_lsp_commit_characters,
workDoneProgress: false,
} : undefined,
hoverProvider: configHandlers.hover,
definitionProvider: configHandlers.definition,
implementationProvider: configHandlers.implementation,
referencesProvider: configHandlers.reference,
renameProvider: configHandlers.rename,
documentFormattingProvider: configHandlers.formatting,
documentRangeFormattingProvider: configHandlers.formatRange,
foldingRangeProvider: configHandlers.folding,
selectionRangeProvider: configHandlers.selectionRange,
codeActionProvider: configHandlers.codeAction ? {
codeActionKinds: [...AllSupportedActions],
workDoneProgress: true,
resolveProvider: true,
} : undefined,
executeCommandProvider: configHandlers.executeCommand ? {
commands: [...AllSupportedActions, ...LspCommands],
workDoneProgress: true,
} : undefined,
documentSymbolProvider: {
label: 'fish-lsp',
},
workspaceSymbolProvider: {
resolveProvider: true,
},
documentHighlightProvider: configHandlers.highlight,
inlayHintProvider: configHandlers.inlayHint,
semanticTokensProvider: configHandlers.semanticTokens ? {
legend: FishSemanticTokens.legend,
range: true,
full: { delta: false },
} : undefined,
signatureHelpProvider: configHandlers.signature ? { workDoneProgress: false, triggerCharacters: ['.'] } : undefined,
documentOnTypeFormattingProvider: configHandlers.typeFormatting ? {
firstTriggerCharacter: '.',
moreTriggerCharacter: [';', '}', ']', ')'],
} : undefined,
// linkedEditingRangeProvider: configHandlers.linkedEditingRange,
// Add this for workspace folder support:
workspace: {
workspaceFolders: {
supported: true,
changeNotifications: true,
},
},
},
serverInfo,
};
}
/**
* *******************************************
* *** initializeResult ***
* *******************************************
* * The `initializeResult` is the result of the `initialize` method
*/
export function initialize(params: InitializeParams, connection: Connection) {
updateFromInitializationOptions(params.initializationOptions);
createServerLogger(config.fish_lsp_log_file, connection.console);
const result = getResultCapabilities();
logger.log({ onInitializedResult: result });
return result;
}
}
// create config to be used globally
export const { config, environmentVariablesUsed } = getConfigFromEnvironmentVariables();
================================================
FILE: src/diagnostics/buffered-async-cache.ts
================================================
import { Diagnostic, DocumentUri } from 'vscode-languageserver';
import { analyzer } from '../analyze';
import { documents, LineSpan, rangeOverlapsLineSpan } from '../document';
import { configHandlers } from '../config';
import { getDiagnosticsAsync } from './validate';
import { connection } from '../utils/startup';
import { logger } from '../logger';
import { config } from '../config';
/**
* Buffered async diagnostic cache that:
* 1. Debounces diagnostic updates to avoid recalculating on every keystroke
* 2. Processes diagnostics asynchronously with yielding to avoid blocking main thread
* 3. Supports cancellation of outdated diagnostic calculations
* 4. Automatically sends diagnostics to the client when ready
*
* This provides a significant performance improvement over the synchronous
* DiagnosticCache, especially for large documents.
*/
export class BufferedAsyncDiagnosticCache {
private cache: Map = new Map();
private pendingCalculations: Map = new Map();
private debounceTimers: Map = new Map();
// Debounce delay in milliseconds
// Diagnostics won't run until user stops typing for this duration
private readonly DEBOUNCE_MS = 400;
/**
* Request a diagnostic update for a document.
* If immediate=false, the update will be debounced.
* If immediate=true, the update runs right away.
*
* @param uri - Document URI to update diagnostics for
* @param immediate - If true, skip debouncing and run immediately
*/
requestUpdate(uri: DocumentUri, immediate = false, changedSpan?: LineSpan): void {
logger.debug({
message: 'BufferedAsyncDiagnosticCache: Requesting diagnostic update',
uri,
immediate,
changedSpan: {
start: changedSpan?.start,
end: changedSpan?.end,
isFullDocument: changedSpan?.isFullDocument || false,
},
diagnostics: this.cache.get(uri)?.map(d => ({
code: d.code,
range: d.range.start.line + '-' + d.range.end.line,
})),
diagnosticsPending: this.isPending(uri),
debounceTimer: {
timer: this.debounceTimers.get(uri),
has: this.debounceTimers.has(uri),
},
});
if (config.fish_lsp_disabled_handlers.includes('diagnostic')) {
return;
}
// Log the change span for debugging purposes
if (changedSpan && !changedSpan.isFullDocument) {
const prev = this.cache.get(uri);
if (prev && prev.length > 0) {
const filtered = prev.filter(
(d) => !rangeOverlapsLineSpan(d.range, changedSpan, 1),
);
if (filtered.length !== prev.length && filtered.length > 0) {
// We removed at least one diagnostic in the edited area.
// Update cache & immediately send the reduced set so UI clears.
this.cache.set(uri, filtered);
connection.sendDiagnostics({ uri, diagnostics: filtered });
logger.debug(
'BufferedAsyncDiagnosticCache: Optimistically cleared stale diagnostics in edited span',
{ uri, removed: prev.length - filtered.length },
);
}
}
}
// Clear any existing debounce timer for this URI
const existingTimer = this.debounceTimers.get(uri);
if (existingTimer) {
clearTimeout(existingTimer);
this.debounceTimers.delete(uri);
logger.debug('BufferedAsyncDiagnosticCache: Cleared existing debounce timer', { uri });
}
if (immediate) {
// Run immediately without debouncing
this.update(uri);
} else {
// Debounce: wait DEBOUNCE_MS before running
const timer = setTimeout(() => {
this.update(uri);
this.debounceTimers.delete(uri);
}, this.DEBOUNCE_MS);
this.debounceTimers.set(uri, timer);
}
}
/**
* Internal method to actually compute and update diagnostics.
* Cancels any pending calculation for the same URI before starting a new one.
*
* @param uri - Document URI to compute diagnostics for
*/
private async update(uri: DocumentUri): Promise {
// Cancel any existing diagnostic calculation for this URI
const existingController = this.pendingCalculations.get(uri);
if (existingController) {
existingController.abort();
this.pendingCalculations.delete(uri);
}
// Check if diagnostics are disabled
if (!configHandlers.diagnostic) {
connection.sendDiagnostics({ uri, diagnostics: [] });
return;
}
const doc = documents.get(uri);
if (!doc) {
logger.debug('BufferedAsyncDiagnosticCache: Document not found', { uri });
connection.sendDiagnostics({ uri, diagnostics: [] });
return;
}
const cachedDoc = analyzer.ensureCachedDocument(doc);
if (!cachedDoc?.root) {
logger.debug('BufferedAsyncDiagnosticCache: Document has no syntax tree', { uri });
connection.sendDiagnostics({ uri, diagnostics: [] });
return;
}
// Create abort controller for this calculation
// This allows us to cancel if the document changes again
const controller = new AbortController();
this.pendingCalculations.set(uri, controller);
try {
// Run async diagnostic calculation (non-blocking!)
// This will yield to the event loop periodically
const diagnostics = await getDiagnosticsAsync(
cachedDoc.root,
doc,
controller.signal,
);
// Check if the calculation was aborted while running
if (controller.signal.aborted) {
logger.debug('BufferedAsyncDiagnosticCache: Calculation was cancelled', { uri });
connection.sendDiagnostics({ uri, diagnostics: [] });
return;
}
// Update cache
this.cache.set(uri, diagnostics);
// Send diagnostics to client
connection.sendDiagnostics({ uri, diagnostics });
logger.debug('BufferedAsyncDiagnosticCache: Diagnostics updated', {
uri,
count: diagnostics.length,
});
} catch (error) {
// Only log errors if the calculation wasn't aborted
if (!controller.signal.aborted) {
logger.error('BufferedAsyncDiagnosticCache: Error calculating diagnostics', {
uri,
error: error instanceof Error ? error.message : String(error),
});
}
} finally {
// Clean up the pending calculation
this.pendingCalculations.delete(uri);
}
}
/**
* Delete diagnostics for a document.
* Cancels any pending calculations and clears cached diagnostics.
* Sends empty diagnostics array to client to clear UI.
*
* @param uri - Document URI to delete diagnostics for
*/
delete(uri: DocumentUri): void {
// Cancel debounce timer if exists
const timer = this.debounceTimers.get(uri);
if (timer) {
clearTimeout(timer);
this.debounceTimers.delete(uri);
}
// Cancel pending calculation if exists
const controller = this.pendingCalculations.get(uri);
if (controller) {
controller.abort();
this.pendingCalculations.delete(uri);
}
// Remove from cache
this.cache.delete(uri);
// Clear diagnostics in client UI
connection.sendDiagnostics({ uri, diagnostics: [] });
logger.debug('BufferedAsyncDiagnosticCache: Diagnostics deleted', { uri });
}
/**
* Clear all diagnostics.
* Cancels all pending calculations and timers.
*/
clear(): void {
// Cancel all debounce timers
this.debounceTimers.forEach(timer => clearTimeout(timer));
this.debounceTimers.clear();
// Cancel all pending calculations
this.pendingCalculations.forEach(controller => controller.abort());
this.pendingCalculations.clear();
// Clear cache
this.cache.clear();
logger.debug('BufferedAsyncDiagnosticCache: All diagnostics cleared');
}
/**
* Get cached diagnostics for a document.
* Returns undefined if not cached.
* Note: This returns the cached value immediately, it does not trigger computation.
*
* @param uri - Document URI to get diagnostics for
* @returns Cached diagnostics or undefined
*/
get(uri: DocumentUri): Diagnostic[] | undefined {
return this.cache.get(uri);
}
/**
* Check if a document has cached diagnostics.
*
* @param uri - Document URI to check
* @returns true if diagnostics are cached
*/
has(uri: DocumentUri): boolean {
return this.cache.has(uri);
}
/**
* Get the number of pending diagnostic calculations.
* Useful for debugging or status indicators.
*
* @returns Number of documents with pending calculations
*/
get pendingCount(): number {
return this.pendingCalculations.size;
}
/**
* Check if diagnostics are currently being calculated for a document.
*
* @param uri - Document URI to check
* @returns true if diagnostics are being calculated
*/
isPending(uri: DocumentUri): boolean {
return this.pendingCalculations.has(uri);
}
public setForTesting(uri: DocumentUri, diagnostics: Diagnostic[]) {
this.cache.set(uri, diagnostics);
}
}
================================================
FILE: src/diagnostics/comments-handler.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isComment } from '../utils/node-types';
import { ErrorCodes } from './error-codes';
import { config } from '../config';
import { Position } from 'vscode-languageserver';
export type DiagnosticAction = 'enable' | 'disable';
export type DiagnosticTarget = 'line' | 'next-line';
export interface DiagnosticComment {
action: DiagnosticAction;
target: DiagnosticTarget;
codes: ErrorCodes.CodeTypes[];
lineNumber: number;
invalidCodes?: string[]; // Track any invalid codes found during parsing
}
export interface DiagnosticState {
enabledCodes: Set;
comment: DiagnosticComment;
invalidCodes?: string[];
}
/**
* Regular expression to match fish-lsp diagnostic control comments
* Matches patterns like:
* # @fish-lsp-disable
* # @fish-lsp-enable
* # @fish-lsp-disable 1001 1002
* # @fish-lsp-enable 1001
* # @fish-lsp-disable-next-line
* # @fish-lsp-disable-next-line 3002 3001
*/
export const DIAGNOSTIC_COMMENT_REGEX = /^#\s*@fish-lsp-(disable|enable)(?:-(next-line))?\s*([0-9\s]*)?$/;
/**
* Checks if a node is a diagnostic control comment
* @param node The syntax node to check
* @returns true if the node is a diagnostic control comment
*/
export function isDiagnosticComment(node: SyntaxNode): boolean {
if (!isComment(node)) return false;
return DIAGNOSTIC_COMMENT_REGEX.test(node.text.trim());
}
export function isValidErrorCode(code: number): code is ErrorCodes.CodeTypes {
return Object.values(ErrorCodes).includes(code as ErrorCodes.CodeTypes);
}
/**
* Parses a diagnostic comment node into its components
* @param node The syntax node to parse
* @returns DiagnosticComment object containing the parsed information
*/
export function parseDiagnosticComment(node: SyntaxNode): DiagnosticComment | null {
if (!isDiagnosticComment(node)) return null;
const match = node.text.trim().match(DIAGNOSTIC_COMMENT_REGEX);
if (!match) return null;
const [, action, nextLine, codesStr] = match;
const codeStrings = codesStr ? codesStr.trim().split(/\s+/) : [];
// Parse the diagnostic codes if present
const parsedCodes = codeStrings
.map(codeStr => parseInt(codeStr, 10))
.filter(code => !isNaN(code));
const validCodes: ErrorCodes.CodeTypes[] = [];
const invalidCodes: string[] = [];
codeStrings.forEach((codeStr, idx) => {
const code = parsedCodes[idx];
if (code && !isNaN(code) && isValidErrorCode(code)) {
validCodes.push(code);
} else {
invalidCodes.push(codeStr);
}
});
return {
action: action as DiagnosticAction,
target: nextLine ? 'next-line' : 'line',
codes: validCodes.length > 0 ? validCodes : ErrorCodes.allErrorCodes,
lineNumber: node.startPosition.row,
invalidCodes: invalidCodes.length > 0 ? invalidCodes : undefined,
};
}
/**
* Represents a diagnostic control point that affects code diagnostics
*/
interface DiagnosticControlPoint {
line: number;
action: DiagnosticAction;
codes: ErrorCodes.CodeTypes[];
isNextLine?: boolean;
}
/**
* Structure to track diagnostic state at a specific line
*/
interface LineState {
enabledCodes: Set;
}
export class DiagnosticCommentsHandler {
// Original stack-based state for compatibility during parsing
private stateStack: DiagnosticState[] = [];
// Track all control points (sorted by line number) for position-based lookups
private controlPoints: DiagnosticControlPoint[] = [];
// Map of line numbers to their effective states (calculated at the end)
private lineStateMap: Map = new Map();
// Track invalid codes for reporting
public invalidCodeWarnings: Map = new Map();
// Cached enabled comments for current state
public enabledComments: ErrorCodes.CodeTypes[] = [];
constructor() {
// Initialize with global state
this.pushState(this.initialState);
this.enabledComments = Array.from(this.currentState.enabledCodes);
}
private get initialState(): DiagnosticState {
return {
enabledCodes: new Set(this.globalEnabledCodes()),
comment: {
action: 'enable',
target: 'line',
codes: this.globalEnabledCodes(),
lineNumber: -1,
},
};
}
private get rootState(): DiagnosticState {
return this.stateStack[0]!;
}
private get currentState(): DiagnosticState {
return this.stateStack[this.stateStack.length - 1]!;
}
private globalEnabledCodes(): ErrorCodes.CodeTypes[] {
const codes = ErrorCodes.allErrorCodes;
if (config.fish_lsp_diagnostic_disable_error_codes.length > 0) {
return codes.filter(
code => !config.fish_lsp_diagnostic_disable_error_codes.includes(code),
).filter(code => ErrorCodes.nonDeprecatedErrorCodes.some(e => e.code === code));
}
return codes.filter(code =>
ErrorCodes.nonDeprecatedErrorCodes.some(e => e.code === code),
);
}
private pushState(state: DiagnosticState) {
this.stateStack.push(state);
}
private popState() {
if (this.stateStack.length > 1) { // Keep at least the global state
this.stateStack.pop();
this.enabledComments = Array.from(this.currentState.enabledCodes);
}
}
/**
* Process a node for diagnostic comments
* This maintains both the stack state and records control points
*/
public handleNode(node: SyntaxNode): void {
// Clean up any expired next-line comments
this.cleanupNextLineComments(node.startPosition.row);
// Early return if not a diagnostic comment
if (!isDiagnosticComment(node)) {
return;
}
const comment = parseDiagnosticComment(node);
if (!comment) return;
// Track invalid codes if present
if (comment.invalidCodes && comment.invalidCodes.length > 0) {
this.invalidCodeWarnings.set(comment.lineNumber, comment.invalidCodes);
}
// Process the comment for both backward compatibility and position-based lookups
this.processComment(comment);
}
private processComment(comment: DiagnosticComment) {
// Update stack-based state (for backward compatibility)
const newEnabledCodes = new Set(this.currentState.enabledCodes);
if (comment.action === 'disable') {
comment.codes.forEach(code => newEnabledCodes.delete(code));
} else {
comment.codes.forEach(code => newEnabledCodes.add(code));
}
const newState: DiagnosticState = {
enabledCodes: newEnabledCodes,
comment,
invalidCodes: comment.invalidCodes,
};
// Update control points for position-based lookups
const controlPoint: DiagnosticControlPoint = {
line: comment.lineNumber,
action: comment.action,
codes: comment.codes,
isNextLine: comment.target === 'next-line',
};
this.controlPoints.push(controlPoint);
// Keep control points sorted by line number
this.controlPoints.sort((a, b) => a.line - b.line);
if (comment.target === 'next-line') {
// For next-line, we'll push a new state that will be popped after the line
this.pushState(newState);
} else {
// For regular comments, we'll replace the current state
if (this.stateStack.length > 1) {
this.popState(); // Remove the current state
}
this.pushState(newState);
}
this.enabledComments = Array.from(newEnabledCodes);
}
private cleanupNextLineComments(currentLine: number) {
while (
this.stateStack.length > 1 && // Keep global state
this.currentState.comment.target === 'next-line' &&
currentLine > this.currentState.comment.lineNumber + 1
) {
this.popState();
}
}
/**
* This method is called when all nodes have been processed
* It computes the effective state for each line in the document
*/
public finalizeStateMap(maxLine: number): void {
// Start with initial state
let currentState: LineState = {
enabledCodes: new Set(this.globalEnabledCodes()),
};
// Process all regular control points first
const regularPoints = this.controlPoints.filter(p => !p.isNextLine);
const nextLinePoints: Map = new Map();
// Group next-line control points by target line
for (const point of this.controlPoints) {
if (point.isNextLine) {
const targetLine = point.line + 1;
const existing = nextLinePoints.get(targetLine) || [];
existing.push({ ...point, line: targetLine });
nextLinePoints.set(targetLine, existing);
}
}
// Build line by line state
for (let line = 0; line <= maxLine; line++) {
// Apply regular control points for this line
for (const point of regularPoints) {
if (point.line <= line) {
this.applyControlPointToState(currentState, point);
}
}
// Save the state before applying next-line directives
const baseState = {
enabledCodes: new Set(currentState.enabledCodes),
};
// Apply next-line directives for this line only
const nextLineDirs = nextLinePoints.get(line) || [];
for (const directive of nextLineDirs) {
this.applyControlPointToState(currentState, directive);
}
// Store state for this line
this.lineStateMap.set(line, {
enabledCodes: new Set(currentState.enabledCodes),
});
// Restore base state after next-line directives
if (nextLineDirs.length > 0) {
currentState = baseState;
}
}
}
private applyControlPointToState(state: LineState, point: DiagnosticControlPoint): void {
if (point.action === 'disable') {
// Disable specified codes
for (const code of point.codes) {
state.enabledCodes.delete(code);
}
} else {
// Enable specified codes
for (const code of point.codes) {
state.enabledCodes.add(code);
}
}
}
public isCodeEnabledAtNode(code: ErrorCodes.CodeTypes, node: SyntaxNode): boolean {
const position = { line: node.startPosition.row, character: node.startPosition.column };
return this.isCodeEnabledAtPosition(code, position);
}
/**
* Check if a specific diagnostic code is enabled at a given position
* Will use the pre-computed state if available, otherwise computes on-demand
*/
public isCodeEnabledAtPosition(code: ErrorCodes.CodeTypes, position: Position): boolean {
if (this.lineStateMap.has(position.line)) {
// Use pre-computed state if available
const state = this.lineStateMap.get(position.line)!;
return state.enabledCodes.has(code);
}
// Compute state on-demand if not pre-computed
return this.computeStateAtPosition(position).enabledCodes.has(code);
}
/**
* Compute state at a position on-demand (used if finalizeStateMap hasn't been called)
*/
private computeStateAtPosition(position: Position): LineState {
// Start with global state
const state: LineState = {
enabledCodes: new Set(this.globalEnabledCodes()),
};
// Apply all regular control points up to this position
for (const point of this.controlPoints) {
if (point.line > position.line) {
break; // Skip control points after this position
}
if (!point.isNextLine && point.line <= position.line) {
this.applyControlPointToState(state, point);
}
// Apply next-line directives for the specific line
if (point.isNextLine && point.line + 1 === position.line) {
this.applyControlPointToState(state, { ...point, line: position.line });
}
}
return state;
}
/**
* Check if a specific diagnostic code is enabled in the current state
* This is for backward compatibility during parsing
*/
public isCodeEnabled(code: ErrorCodes.CodeTypes): boolean {
return this.currentState.enabledCodes.has(code);
}
public isRootEnabled(code: ErrorCodes.CodeTypes): boolean {
return this.rootState.enabledCodes.has(code);
}
public getStackDepth(): number {
return this.stateStack.length;
}
public getCurrentState(): DiagnosticState {
return this.currentState;
}
public * stateIterator(): IterableIterator {
for (const state of this.stateStack) {
yield state;
}
}
/**
* For debugging/testing - get verbose state information
*/
public getCurrentStateVerbose() {
const currentState = this.getCurrentState();
const disabledCodes = ErrorCodes.allErrorCodes.filter(e => !currentState.enabledCodes.has(e));
const enabledCodes = Array.from(currentState.enabledCodes)
.map(e => ErrorCodes.codes[e].code)
.concat(disabledCodes)
.sort((a, b) => a - b)
.map(item => {
if (disabledCodes.includes(item)) return '....';
return item;
})
.join(' | ');
const invalidCodes = Array.from(this.invalidCodeWarnings.entries())
.map(([line, codes]) => `${line}: ${codes.join(' | ')}`);
const lineStates = Array.from(this.lineStateMap.entries())
.map(([line, state]) => `Line ${line}: ${Array.from(state.enabledCodes).length} enabled codes`);
return {
depth: this.getStackDepth(),
enabledCodes: enabledCodes,
invalidCodes: invalidCodes,
currentState: {
action: currentState.comment.action,
target: currentState.comment.target,
codes: currentState.comment?.codes.join(' | '),
lineNumber: currentState.comment.lineNumber,
},
controlPoints: this.controlPoints.length,
lineStates: lineStates.slice(0, 10), // Show first 10 for brevity
};
}
}
================================================
FILE: src/diagnostics/diagnostic-ranges.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { ErrorCodes } from './error-codes';
import { config } from '../config';
import { isComment } from '../utils/node-types';
import { nodesGen } from '../utils/tree-sitter';
/**
* Represents a range where a specific diagnostic code is disabled
*/
export interface DisabledRange {
startLine: number;
endLine: number; // -1 means until end of file
code: ErrorCodes.CodeTypes; // Single code per range for clarity
}
/**
* Result of pre-computing diagnostic ranges
*/
export interface DiagnosticRangesResult {
/** Ranges where specific codes are disabled (one range per code) */
disabledRanges: DisabledRange[];
/** Lines with invalid diagnostic codes in comments (for reporting) */
invalidCodeLines: Map;
/** Total number of diagnostic comments found */
commentCount: number;
/** Time taken to compute ranges (ms) */
computeTimeMs: number;
}
/**
* Regular expression to match fish-lsp diagnostic control comments
*/
const DIAGNOSTIC_COMMENT_REGEX = /^#\s*@fish-lsp-(disable|enable)(?:-(next-line))?\s*([0-9\s]*)?$/;
/**
* Check if a code is a valid ErrorCode
*/
function isValidErrorCode(code: number): code is ErrorCodes.CodeTypes {
return Object.values(ErrorCodes).includes(code as ErrorCodes.CodeTypes);
}
/**
* Get the globally enabled codes based on config
*/
function getGlobalEnabledCodes(): Set {
const codes = ErrorCodes.allErrorCodes.filter(code =>
ErrorCodes.nonDeprecatedErrorCodes.some(e => e.code === code),
);
if (config.fish_lsp_diagnostic_disable_error_codes.length > 0) {
return new Set(
codes.filter(code => !config.fish_lsp_diagnostic_disable_error_codes.includes(code)),
);
}
return new Set(codes);
}
interface ParsedComment {
action: 'enable' | 'disable';
isNextLine: boolean;
codes: ErrorCodes.CodeTypes[];
allCodes: boolean; // true if no specific codes were specified (applies to all)
lineNumber: number;
invalidCodes: string[];
}
/**
* Parse a diagnostic comment from a syntax node
*/
function parseDiagnosticComment(node: SyntaxNode): ParsedComment | null {
if (!isComment(node)) return null;
const match = node.text.trim().match(DIAGNOSTIC_COMMENT_REGEX);
if (!match) return null;
const [, action, nextLine, codesStr] = match;
const codeStrings = codesStr ? codesStr.trim().split(/\s+/).filter(s => s.length > 0) : [];
const validCodes: ErrorCodes.CodeTypes[] = [];
const invalidCodes: string[] = [];
for (const codeStr of codeStrings) {
const code = parseInt(codeStr, 10);
if (!isNaN(code) && isValidErrorCode(code)) {
validCodes.push(code);
} else if (codeStr.length > 0) {
invalidCodes.push(codeStr);
}
}
// If no codes specified, it applies to ALL codes
const allCodes = validCodes.length === 0;
const codes = allCodes ? Array.from(getGlobalEnabledCodes()) : validCodes;
return {
action: action as 'enable' | 'disable',
isNextLine: !!nextLine,
codes,
allCodes,
lineNumber: node.startPosition.row,
invalidCodes,
};
}
/**
* Pre-compute diagnostic disabled ranges from the syntax tree.
*
* Handles cascading/overlapping disables correctly:
* - `# @fish-lsp-disable 1001` on line 10 disables 1001 from line 10 onwards
* - `# @fish-lsp-disable 1002` on line 20 ALSO disables 1002, but 1001 stays disabled
* - `# @fish-lsp-enable` (no codes) re-enables ALL codes
* - `# @fish-lsp-enable 1001` only re-enables 1001
*
* @param root - The root syntax node of the document
* @param maxLine - The maximum line number in the document
* @returns DiagnosticRangesResult with computed ranges
*/
export function computeDiagnosticRanges(root: SyntaxNode, maxLine: number): DiagnosticRangesResult {
const startTime = performance.now();
const disabledRanges: DisabledRange[] = [];
const invalidCodeLines = new Map();
let commentCount = 0;
// Track currently active disables PER CODE (cascading support)
// Map: code -> startLine where it was disabled
const activeDisables = new Map();
// Collect all diagnostic comments first
const comments: ParsedComment[] = [];
for (const node of nodesGen(root)) {
if (!isComment(node)) continue;
const parsed = parseDiagnosticComment(node);
if (parsed) {
comments.push(parsed);
commentCount++;
if (parsed.invalidCodes.length > 0) {
invalidCodeLines.set(parsed.lineNumber, parsed.invalidCodes);
}
}
}
// Sort comments by line number
comments.sort((a, b) => a.lineNumber - b.lineNumber);
// Process comments to build ranges
for (const comment of comments) {
if (comment.isNextLine) {
// Next-line comments create single-line disabled ranges
if (comment.action === 'disable') {
for (const code of comment.codes) {
disabledRanges.push({
startLine: comment.lineNumber + 1,
endLine: comment.lineNumber + 1,
code,
});
}
}
// Note: enable-next-line is rare and would temporarily re-enable within a disabled block
// For simplicity, we don't support this edge case
} else {
// Regular disable/enable comments
if (comment.action === 'disable') {
// Start tracking each code independently
for (const code of comment.codes) {
if (!activeDisables.has(code)) {
// Only start a new range if not already disabled
activeDisables.set(code, comment.lineNumber);
}
// If already disabled, the existing disable continues (no action needed)
}
} else {
// Enable comment - close ranges for the specified codes
for (const code of comment.codes) {
const startLine = activeDisables.get(code);
if (startLine !== undefined) {
// Close the range for this code
disabledRanges.push({
startLine,
endLine: comment.lineNumber - 1,
code,
});
activeDisables.delete(code);
}
}
}
}
}
// Close any remaining active disables (extend to end of file)
for (const [code, startLine] of activeDisables.entries()) {
disabledRanges.push({
startLine,
endLine: maxLine,
code,
});
}
const computeTimeMs = performance.now() - startTime;
return {
disabledRanges,
invalidCodeLines,
commentCount,
computeTimeMs,
};
}
/**
* Fast lookup class for checking if a diagnostic code is enabled at a specific line
*/
export class DiagnosticRangeChecker {
private disabledRanges: DisabledRange[];
private globalEnabledCodes: Set;
// Optimized: Pre-compute a map of line -> disabled codes for fast lookup
private lineDisabledCodes: Map> = new Map();
private maxPrecomputedLine: number = -1;
constructor(ranges: DiagnosticRangesResult, maxLine?: number) {
this.disabledRanges = ranges.disabledRanges;
this.globalEnabledCodes = getGlobalEnabledCodes();
// Pre-compute line lookup map if maxLine is provided
if (maxLine !== undefined && maxLine <= 10000) {
this.precomputeLineLookup(maxLine);
}
}
/**
* Pre-compute disabled codes for each line for O(1) lookup
*/
private precomputeLineLookup(maxLine: number): void {
for (let line = 0; line <= maxLine; line++) {
const disabledCodes = new Set();
for (const range of this.disabledRanges) {
const endLine = range.endLine === -1 ? maxLine : range.endLine;
if (line >= range.startLine && line <= endLine) {
disabledCodes.add(range.code);
}
}
if (disabledCodes.size > 0) {
this.lineDisabledCodes.set(line, disabledCodes);
}
}
this.maxPrecomputedLine = maxLine;
}
/**
* Check if a specific diagnostic code is enabled at a given line
* O(1) if pre-computed, O(ranges) otherwise
*/
isCodeEnabledAtLine(code: ErrorCodes.CodeTypes, line: number): boolean {
// Check if globally disabled first
if (!this.globalEnabledCodes.has(code)) {
return false;
}
// Use pre-computed lookup if available
if (line <= this.maxPrecomputedLine) {
const disabled = this.lineDisabledCodes.get(line);
return !disabled || !disabled.has(code);
}
// Fall back to range checking
for (const range of this.disabledRanges) {
const endLine = range.endLine === -1 ? Infinity : range.endLine;
if (line >= range.startLine && line <= endLine && range.code === code) {
return false;
}
}
return true;
}
/**
* Check if a specific diagnostic code is enabled at a node's position
*/
isCodeEnabledAtNode(code: ErrorCodes.CodeTypes, node: SyntaxNode): boolean {
return this.isCodeEnabledAtLine(code, node.startPosition.row);
}
/**
* Get all disabled codes at a specific line (for debugging)
*/
getDisabledCodesAtLine(line: number): ErrorCodes.CodeTypes[] {
if (line <= this.maxPrecomputedLine) {
const codes = this.lineDisabledCodes.get(line);
return codes ? Array.from(codes) : [];
}
const disabled: ErrorCodes.CodeTypes[] = [];
for (const range of this.disabledRanges) {
const endLine = range.endLine === -1 ? Infinity : range.endLine;
if (line >= range.startLine && line <= endLine) {
disabled.push(range.code);
}
}
return [...new Set(disabled)];
}
/**
* Get summary information about the computed ranges
*/
getSummary(): {
totalRanges: number;
precomputedLines: number;
linesWithDisabledCodes: number;
} {
return {
totalRanges: this.disabledRanges.length,
precomputedLines: this.maxPrecomputedLine + 1,
linesWithDisabledCodes: this.lineDisabledCodes.size,
};
}
/**
* Debug: Get detailed state at a specific line
*/
getLineState(line: number): {
line: number;
disabledCodes: ErrorCodes.CodeTypes[];
enabledCodes: ErrorCodes.CodeTypes[];
} {
const disabledCodes = this.getDisabledCodesAtLine(line);
const disabledSet = new Set(disabledCodes);
const enabledCodes = Array.from(this.globalEnabledCodes).filter(c => !disabledSet.has(c));
return {
line,
disabledCodes,
enabledCodes,
};
}
}
/**
* Convenience function to create a DiagnosticRangeChecker from a syntax tree
*/
export function createDiagnosticChecker(root: SyntaxNode, maxLine: number): {
checker: DiagnosticRangeChecker;
result: DiagnosticRangesResult;
} {
const result = computeDiagnosticRanges(root, maxLine);
const checker = new DiagnosticRangeChecker(result, maxLine);
return { checker, result };
}
================================================
FILE: src/diagnostics/error-codes.ts
================================================
import { CodeDescription, DiagnosticSeverity } from 'vscode-languageserver';
export namespace ErrorCodes {
export const missingEnd = 1001;
export const extraEnd = 1002;
export const zeroIndexedArray = 1003;
export const sourceFileDoesNotExist = 1004;
export const dotSourceCommand = 1005;
export const singleQuoteVariableExpansion = 2001;
export const usedWrapperFunction = 2002;
export const usedUnviersalDefinition = 2003;
export const usedExternalShellCommandWhenBuiltinExists = 2004;
export const testCommandMissingStringCharacters = 3001;
export const missingQuietOption = 3002;
export const dereferencedDefinition = 3003;
export const autoloadedFunctionMissingDefinition = 4001;
export const autoloadedFunctionFilenameMismatch = 4002;
export const functionNameUsingReservedKeyword = 4003;
export const unusedLocalDefinition = 4004;
export const autoloadedCompletionMissingCommandName = 4005;
export const duplicateFunctionDefinitionInSameScope = 4006;
export const autoloadedFunctionWithEventHookUnused = 4007;
export const requireAutloadedFunctionHasDescription = 4008;
export const argparseMissingEndStdin = 5001;
export const unreachableCode = 5555;
export const fishLspDeprecatedEnvName = 6001;
export const unknownCommand = 7001;
export const invalidDiagnosticCode = 8001;
export const syntaxError = 9999;
export type CodeTypes =
1001 | 1002 | 1003 | 1004 | 1005 |
2001 | 2002 | 2003 | 2004 |
3001 | 3002 | 3003 |
4001 | 4002 | 4003 | 4004 | 4005 | 4006 | 4007 | 4008 |
5001 | 5555 |
6001 |
7001 |
8001 |
9999;
export type CodeValueType = {
severity: DiagnosticSeverity;
code: CodeTypes;
codeDescription: CodeDescription;
source: 'fish-lsp';
isDeprecated?: boolean;
message: string;
};
export type DiagnosticCode = {
[k in CodeTypes]: CodeValueType;
};
export const codes: { [k in CodeTypes]: CodeValueType } = {
[missingEnd]: {
severity: DiagnosticSeverity.Error,
code: missingEnd,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/end.html' },
source: 'fish-lsp',
message: 'missing closing token',
},
[extraEnd]: {
severity: DiagnosticSeverity.Error,
code: extraEnd,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/end.html' },
source: 'fish-lsp',
message: 'extra closing token',
},
[zeroIndexedArray]: {
severity: DiagnosticSeverity.Error,
code: zeroIndexedArray,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#slices' },
source: 'fish-lsp',
message: 'invalid array index',
},
[sourceFileDoesNotExist]: {
severity: DiagnosticSeverity.Error,
code: sourceFileDoesNotExist,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/source.html' },
source: 'fish-lsp',
message: 'source filename does not exist',
},
[dotSourceCommand]: {
severity: DiagnosticSeverity.Error,
code: dotSourceCommand,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/source.html' },
source: 'fish-lsp',
message: '`.` source command not allowed, use `source` instead',
},
/** consider disabling this */
[singleQuoteVariableExpansion]: {
severity: DiagnosticSeverity.Warning,
code: singleQuoteVariableExpansion,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#variable-expansion' },
source: 'fish-lsp',
isDeprecated: true,
message: 'non-escaped expansion variable in single quote string',
},
[usedWrapperFunction]: {
severity: DiagnosticSeverity.Warning,
code: usedWrapperFunction,
codeDescription: { href: 'https://fishshell.com/docs/current/commands.html' },
source: 'fish-lsp',
message: 'Wrapper command (`export`, `alias`, etc.) used, while preferring usage of primitive commands.\n\nUse command: \n```fish\nset -gx fish_lsp_allow_fish_wrapper_functions true\n```\nto disable this warning globally.',
},
[usedUnviersalDefinition]: {
severity: DiagnosticSeverity.Warning,
code: usedUnviersalDefinition,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#universal-variables' },
source: 'fish-lsp',
message: 'Universal scope set in non-interactive session',
},
[usedExternalShellCommandWhenBuiltinExists]: {
severity: DiagnosticSeverity.Warning,
code: usedExternalShellCommandWhenBuiltinExists,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/builtins.html' },
source: 'fish-lsp',
message: 'External shell command used when equivalent fish builtin exists',
},
[testCommandMissingStringCharacters]: {
severity: DiagnosticSeverity.Warning,
code: testCommandMissingStringCharacters,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/test.html#examples' },
source: 'fish-lsp',
message: 'test command string check, should be wrapped as a string',
},
[missingQuietOption]: {
severity: DiagnosticSeverity.Warning,
code: missingQuietOption,
codeDescription: { href: 'https://fishshell.com/docs/current/search.html?q=-q' },
source: 'fish-lsp',
message: 'Conditional command should include a silence option',
},
[dereferencedDefinition]: {
severity: DiagnosticSeverity.Warning,
code: dereferencedDefinition,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#dereferencing-variables' },
source: 'fish-lsp',
message: 'Dereferenced variable could be undefined',
},
[autoloadedFunctionMissingDefinition]: {
severity: DiagnosticSeverity.Warning,
code: autoloadedFunctionMissingDefinition,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/functions.html' },
source: 'fish-lsp',
message: 'Autoloaded function missing definition',
},
[autoloadedFunctionFilenameMismatch]: {
severity: DiagnosticSeverity.Error,
code: autoloadedFunctionFilenameMismatch,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/functions.html' },
source: 'fish-lsp',
message: 'Autoloaded filename does not match function name',
},
[functionNameUsingReservedKeyword]: {
severity: DiagnosticSeverity.Error,
code: functionNameUsingReservedKeyword,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/functions.html' },
source: 'fish-lsp',
message: 'Function name uses reserved keyword',
},
[unusedLocalDefinition]: {
severity: DiagnosticSeverity.Warning,
code: unusedLocalDefinition,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#local-variables' },
source: 'fish-lsp',
message: 'Unused local',
},
[autoloadedCompletionMissingCommandName]: {
severity: DiagnosticSeverity.Error,
code: autoloadedCompletionMissingCommandName,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/complete.html' },
source: 'fish-lsp',
message: 'Autoloaded completion missing command name',
},
[duplicateFunctionDefinitionInSameScope]: {
severity: DiagnosticSeverity.Warning,
code: duplicateFunctionDefinitionInSameScope,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/functions.html' },
source: 'fish-lsp',
message: 'Duplicate function definition exists in the same scope.\n\nAmbiguous function',
},
[autoloadedFunctionWithEventHookUnused]: {
severity: DiagnosticSeverity.Warning,
code: autoloadedFunctionWithEventHookUnused,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#event' },
source: 'fish-lsp',
message: 'Autoloaded function with event hook is unused',
},
[requireAutloadedFunctionHasDescription]: {
severity: DiagnosticSeverity.Warning,
code: requireAutloadedFunctionHasDescription,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/functions.html' },
source: 'fish-lsp',
message: 'Autoloaded function requires a description | Add `-d`/`--description` to the function definition',
},
[argparseMissingEndStdin]: {
severity: DiagnosticSeverity.Error,
code: argparseMissingEndStdin,
codeDescription: { href: 'https://fishshell.com/docs/current/cmds/argparse.html' },
source: 'fish-lsp',
message: 'argparse missing end of stdin',
},
[unreachableCode]: {
severity: DiagnosticSeverity.Warning,
code: unreachableCode,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#unreachable-code-blocks' },
source: 'fish-lsp',
message: 'Unreachable code blocks detected',
},
[fishLspDeprecatedEnvName]: {
severity: DiagnosticSeverity.Warning,
code: fishLspDeprecatedEnvName,
codeDescription: { href: 'https://github.com/ndonfris/fish-lsp#environment-variables' },
source: 'fish-lsp',
message: 'Deprecated fish-lsp environment variable name',
},
[unknownCommand]: {
severity: DiagnosticSeverity.Warning,
code: unknownCommand,
codeDescription: { href: 'https://fishshell.com/docs/current/language.html#commands' },
source: 'fish-lsp',
message: 'Unknown command',
},
[invalidDiagnosticCode]: {
severity: DiagnosticSeverity.Warning,
code: invalidDiagnosticCode,
codeDescription: { href: 'https://github.com/ndonfris/fish-lsp/wiki/Diagnostic-Error-Codes' },
source: 'fish-lsp',
message: 'Invalid diagnostic control code',
},
[syntaxError]: {
severity: DiagnosticSeverity.Error,
code: syntaxError,
codeDescription: { href: 'https://fishshell.com/docs/current/fish_for_bash_users.html#syntax-overview' },
source: 'fish-lsp',
message: 'fish syntax error',
},
};
/** All error codes */
export const allErrorCodes = Object.values(codes).map((diagnostic) => diagnostic.code) as CodeTypes[];
export const allErrorCodeObjects = Object.values(codes) as CodeValueType[];
export const nonDeprecatedErrorCodes = allErrorCodeObjects.filter((code) => !code.isDeprecated);
export function getSeverityString(severity: DiagnosticSeverity | undefined): string {
if (!severity) return '';
switch (severity) {
case DiagnosticSeverity.Error:
return 'Error';
case DiagnosticSeverity.Warning:
return 'Warning';
case DiagnosticSeverity.Information:
return 'Information';
case DiagnosticSeverity.Hint:
return 'Hint';
default:
return '';
}
}
export function codeTypeGuard(code: CodeTypes | number | string | undefined): code is CodeTypes {
return typeof code === 'number' && code >= 1000 && code < 10000 && allErrorCodes.includes(code as CodeTypes);
}
export function getDiagnostic(code: CodeTypes | number): CodeValueType {
if (typeof code === 'number') return codes[code as CodeTypes];
return codes[code];
}
}
================================================
FILE: src/diagnostics/invalid-error-code.ts
================================================
import { Diagnostic } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { ErrorCodes } from './error-codes';
import { isComment } from '../utils/node-types';
import { logger } from '../logger';
import { FishDiagnostic } from './types';
// More precise regex to capture exact positions of code numbers
const DIAGNOSTIC_COMMENT_REGEX = /^#\s*@fish-lsp-(disable|enable)(?:-(next-line))?\s/;
export function isPossibleDiagnosticComment(node: SyntaxNode): boolean {
if (!isComment(node)) return false;
return DIAGNOSTIC_COMMENT_REGEX.test(node.text.trim());
}
// Function to find codes with their positions
function findCodes(text: string): { code: string; startIndex: number; }[] {
// Find where the codes section starts (after the directive)
const directiveMatch = text.match(/@fish-lsp-(?:disable|enable)(?:-next-line)?/); // remove leading comment
if (!directiveMatch) return [];
const codesStart = directiveMatch.index! + directiveMatch[0].length;
const codesSection = text.slice(codesStart);
// Find all code tokens in the codes section
const result: { code: string; startIndex: number; }[] = [];
const codeRegex = /(\d+)/g;
let match;
while ((match = codeRegex.exec(codesSection)) !== null) {
result.push({
code: match[0],
startIndex: codesStart + match.index,
});
}
logger.log('Found codes:', result, 'on text:', text);
logger.log('Directive:', directiveMatch);
return result;
}
export function detectInvalidDiagnosticCodes(node: SyntaxNode): Diagnostic[] {
// Early return if not a diagnostic comment
if (!isComment(node)) return [];
const text = node.text.trim();
if (!DIAGNOSTIC_COMMENT_REGEX.test(text)) return [];
// Find all code numbers with their positions
const codePositions = findCodes(text);
const diagnostics: Diagnostic[] = [];
for (const { code, startIndex } of codePositions) {
const codeNum = parseInt(code, 10);
// Check if it's a valid error code
if (isNaN(codeNum) || !ErrorCodes.codeTypeGuard(codeNum)) {
// Create diagnostic for this invalid code
const diagnostic = FishDiagnostic.create(ErrorCodes.invalidDiagnosticCode, node, `Invalid diagnostic code: '${code}'. Valid codes are: ${ErrorCodes.allErrorCodes.map(c => c.toString()).join(', ')}.`);
diagnostic.range = {
start: {
line: node.startPosition.row,
character: node.startPosition.column + startIndex,
},
end: {
line: node.startPosition.row,
character: node.startPosition.column + startIndex + code.length,
},
};
diagnostics.push(diagnostic);
}
}
return diagnostics;
}
// Function to add to the validate.ts getDiagnostics function
export function checkForInvalidDiagnosticCodes(node: SyntaxNode): Diagnostic[] {
if (isPossibleDiagnosticComment(node)) {
return detectInvalidDiagnosticCodes(node);
}
return [];
}
================================================
FILE: src/diagnostics/missing-completions.ts
================================================
import { Diagnostic, Location } from 'vscode-languageserver';
import { analyzer } from '../analyze';
import { LspDocument } from '../document';
import { logger } from '../logger';
import { FishSymbol } from '../parsing/symbol';
import { getRange } from '../utils/tree-sitter';
import { CompletionSymbol, getGroupedCompletionSymbolsAsArgparse, groupCompletionSymbolsTogether } from '../parsing/complete';
import { flattenNested } from '../utils/flatten';
import * as Locations from '../utils/locations';
import { uriToReadablePath } from '../utils/translation';
import { equalDiagnostics } from '../code-actions/code-action-handler';
// TODO: add this to the validation.ts file
export function findAllMissingArgparseFlags(
document: LspDocument,
) {
const fishSymbols: FishSymbol[] = [];
const completionSymbols: CompletionSymbol[][] = [];
const diagnostics: Diagnostic[] = [];
if (document.isFunction()) {
const result = findMissingArgparseFlagsWithExistingCompletions(document);
completionSymbols.push(...result);
}
if (document.isAutoloadedWithPotentialCompletions()) {
const result = findMissingCompletionsWithExistingArgparse(document);
fishSymbols.push(...result);
}
if (completionSymbols.length === 0 && fishSymbols.length === 0) {
logger.debug(`No missing argparse flags found in document: ${document.uri}`);
return [];
}
// create diagnostics for missing completions
if (completionSymbols.length > 0) {
for (const completionGroup of completionSymbols) {
const diag = createCompletionDiagnostic(completionGroup, analyzer.getFlatDocumentSymbols(document.uri));
if (diag) {
const toAdd = diag.filter(d => !diagnostics.some(existing => equalDiagnostics(existing, d)));
diagnostics.push(...toAdd);
}
}
}
// create diagnostics for missing argparse flags
if (fishSymbols.length > 0) {
for (const symbol of fishSymbols) {
const diag = createArgparseDiagnostic(symbol, document);
if (diag) {
const toAdd = diag.filter(d => !diagnostics.some(existing => equalDiagnostics(existing, d)));
diagnostics.push(...toAdd);
}
}
}
// Check if the symbol is a command definition
return diagnostics;
}
function findMissingArgparseFlagsWithExistingCompletions(
document: LspDocument,
): CompletionSymbol[][] {
const missingCompletions: CompletionSymbol[][] = [];
/**
* Retrieve all global function symbols in the document.
*/
const symbols = analyzer.getFlatDocumentSymbols(document.uri);
const globalSymbols = symbols.filter(s => s.isGlobal() && s.isFunction());
/**
* Flatten all global autoloaded function symbols,
* and extract their argparse symbols.
*/
const argparseSymbols = flattenNested(...globalSymbols).filter(s => s.fishKind === 'ARGPARSE');
for (const symbol of argparseSymbols) {
// get the locations where the completion symbol is implemented
const completionLocations = analyzer.getImplementation(document, symbol.toPosition())
.filter(loc => !symbol.equalsLocation(loc));
if (completionLocations.length === 0) continue;
for (const location of completionLocations) {
const cSymbols = analyzer
.getFlatCompletionSymbols(location.uri)
.filter(s => s.isNonEmpty());
if (cSymbols.length === 0) continue;
const grouped = groupCompletionSymbolsTogether(...cSymbols);
const result = getGroupedCompletionSymbolsAsArgparse(grouped, argparseSymbols);
if (result.length > 0) {
missingCompletions.push(...result);
}
}
}
return missingCompletions;
}
function findMissingCompletionsWithExistingArgparse(
document: LspDocument,
) {
const missingCompletions: FishSymbol[] = [];
const completionSymbols = analyzer.getFlatCompletionSymbols(document.uri).filter(s => s.isNonEmpty());
const implementationLocations: Location[] = [];
completionSymbols.forEach(s => {
const pos = s.toPosition();
if (!pos) return;
const results = analyzer.getImplementation(document, pos)
.filter(loc => !Locations.Location.equals(loc, s.toLocation()));
if (results.length === 0) return;
implementationLocations.push(...results);
});
const grouped = groupCompletionSymbolsTogether(...completionSymbols);
if (grouped.length === 0) return missingCompletions;
for (const location of implementationLocations) {
const cSymbols = analyzer.getFlatDocumentSymbols(location.uri)
.filter(s => s.fishKind === 'ARGPARSE');
if (cSymbols.length === 0) continue;
for (const symbol of cSymbols) {
if (grouped.some(group => group.some(s => s.equalsArgparse(symbol)))) {
// If the symbol is already in the grouped completions, skip it
continue;
}
missingCompletions.push(symbol);
}
}
return missingCompletions;
}
function createCompletionDiagnostic(completionGroup: CompletionSymbol[], symbols: FishSymbol[]) {
const diagnostics: Diagnostic[] = [];
const focusedSymbol = symbols.find(s => s.isFunction() && s.isGlobal() && completionGroup.every(c => c.commandName === s.name));
if (!focusedSymbol) {
logger.warning(`No focused location found for completion group: ${completionGroup.map(c => c.text).join(', ')}`, 'HERE');
return null;
}
const hasArgparse = flattenNested(focusedSymbol).find(l => l.fishKind === 'ARGPARSE');
const focusedNode = hasArgparse ? hasArgparse.node.firstNamedChild : focusedSymbol.node.firstChild?.nextSibling;
if (!focusedNode) {
logger.warning(`No focused node found for completion group: ${completionGroup.map(c => c.text).join(', ')}`);
return null;
}
const joinedGroup = completionGroup.map(c => c.toArgparseOpt()).join('/');
const firstCompletion = completionGroup.find(c => c.isNonEmpty())!;
const firstCompletionDoc = firstCompletion.document!;
const prettyPath = uriToReadablePath(firstCompletionDoc.uri);
// Create a diagnostic for the completion group
diagnostics.push({
message: `Add missing \`argparse ${joinedGroup}\` from completion in '${prettyPath}'`,
severity: 1, // Warning
source: 'fish-lsp',
code: 4008,
range: getRange(focusedNode),
data: {
node: focusedNode,
},
});
const joinedGroupUsage = completionGroup.map((item, idx) => {
if (idx === 0) {
return item.toUsage();
}
return item.toFlag();
}).join('/');
diagnostics.push({
message: `Remove the unused completion \`${joinedGroupUsage}\` in '${prettyPath}'`,
severity: 1, // Error
source: 'fish-lsp',
code: 4009,
range: getRange(firstCompletion.parent),
data: {
node: firstCompletion.parent,
},
});
return diagnostics;
}
function createArgparseDiagnostic(
symbol: FishSymbol,
document: LspDocument,
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const focusedNode = symbol.focusedNode;
if (!focusedNode) {
logger.warning(`No focused node found for symbol: ${symbol.name}`);
return [];
}
const prettyPath = uriToReadablePath(document.uri);
diagnostics.push({
message: `remove unused \`argparse ${symbol.argparseFlagName}\` in '${prettyPath}'`,
severity: 1, // Warning
source: 'fish-lsp',
code: 4009,
range: getRange(focusedNode),
data: {
type: 'argparse removal',
node: focusedNode,
},
});
const completionLocation = analyzer.getImplementation(document, symbol.toPosition())
.find(loc => !Locations.Location.equals(loc, symbol.toLocation()));
if (completionLocation) {
const prettyCompletionPath = uriToReadablePath(completionLocation.uri);
diagnostics.push({
message: `Add missing \`${symbol.parent!.name + ' ' + symbol.argparseFlag}\` completion in '${prettyCompletionPath}'`,
severity: 1, // Warning
source: 'fish-lsp',
code: 4008,
range: getRange(focusedNode),
data: {
type: 'argparse addition',
node: focusedNode,
},
});
}
return diagnostics;
}
================================================
FILE: src/diagnostics/no-execute-diagnostic.ts
================================================
// import { spawnSync } from 'child_process';
import { LspDocument } from '../document';
import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver';
import { logger } from '../logger';
import { execFishNoExecute } from '../utils/exec';
import { ErrorCodes } from './error-codes';
/**
* A unique diagnostic code to identify issues found by the no-execute diagnostic
*/
const NoExecuteErrorCode = ErrorCodes.syntaxError;
/**
* Parse the output from `fish --no-execute` on a script to identify syntax errors
* @param document The document to run the no-execute check on
* @param testMode If true, returns detailed output for testing purposes
* @returns Array of diagnostics or detailed output if in test mode
*/
export function runNoExecuteDiagnostic(document: LspDocument): Diagnostic[] {
try {
// Get document content
const scriptContent = document.getText();
// Skip empty documents
if (!scriptContent.trim()) {
return [];
}
const result = execFishNoExecute(document.getFilePath()!);
// Process the output and error
if (result) {
logger.log(`Fish --no-execute found output: ${result}`);
return fishOutputToDiagnostics(result, document);
// return parseNoExecuteOutput(result, document);
}
return [];
} catch (error) {
// Log the error but don't throw it
logger.log(`Error in no-execute diagnostic: ${error}`);
return [];
}
}
/**
* The main function to be called from validate.ts
*/
export function getNoExecuteDiagnostics(document: LspDocument): Diagnostic[] {
// Only run on .fish files
if (!document.uri.endsWith('.fish')) {
return [];
}
const diagnostics = runNoExecuteDiagnostic(document);
logger.log(`No-execute diagnostics for ${document.uri}: ${diagnostics.length} issues found`);
return diagnostics;
}
/**
* Parse fish errors from Fish output for a given document.
*
* @param document The document to whose contents errors refer
* @param output The error output from Fish.
* @return An array of all diagnostics
*/
const fishOutputToDiagnostics = (
output: string,
document: LspDocument,
): Diagnostic[] => {
const diagnostics: Array = [];
const matches = getMatches(/^(.+) \(line (\d+)\): (.+)$/gm, output);
for (const match of matches) {
const lineNumber = Number.parseInt(match[2]!);
const message = match[3];
const range = document.getLineRange(lineNumber - 1);
const diagnostic = {
severity: DiagnosticSeverity.Error,
range,
message: `Fish syntax error: ${message}`,
source: 'fish-lsp',
code: NoExecuteErrorCode,
data: { isNoExecute: true, output },
};
diagnostics.push(diagnostic);
}
return diagnostics;
};
/**
* Exec pattern against the given text and return an array of all matches.
*
* @param pattern The pattern to match against
* @param text The text to match the pattern against
* @return All matches of pattern in text.
*/
const getMatches = (
pattern: RegExp,
text: string,
): ReadonlyArray => {
const results = [];
// We need to loop through the regexp here, so a let is required
let match = pattern.exec(text);
while (match !== null) {
results.push(match);
match = pattern.exec(text);
}
return results;
};
================================================
FILE: src/diagnostics/node-types.ts
================================================
import Parser, { SyntaxNode } from 'web-tree-sitter';
import { findParentCommand, hasParent, isCommand, isCommandName, isCommandWithName, isEndStdinCharacter, isFunctionDefinitionName, isIfOrElseIfConditional, isMatchingOption, isOption, isString, isVariableDefinitionName } from '../utils/node-types';
import { getChildNodes, getRange, isNodeWithinOtherNode, precedesRange, TreeWalker } from '../utils/tree-sitter';
import { Maybe } from '../utils/maybe';
import { Option } from '../parsing/options';
import { isExistingSourceFilenameNode, isSourceCommandArgumentName } from '../parsing/source';
import { LspDocument } from '../document';
import { DiagnosticCommentsHandler } from './comments-handler';
import { FishSymbol } from '../parsing/symbol';
import { ErrorCodes } from './error-codes';
import { getReferences } from '../references';
import { config, Config } from '../config';
import { isBuiltin } from '../utils/builtins';
import { server } from '../server';
import { analyzer } from '../analyze';
import { FishCompletionItemKind } from '../utils/completion/types';
type startTokenType = 'function' | 'while' | 'if' | 'for' | 'begin' | 'switch' | '[' | '{' | '(' | "'" | '"';
type endTokenType = 'end' | "'" | '"' | ']' | '}' | ')';
export const ErrorNodeTypes: { [start in startTokenType]: endTokenType } = {
['function']: 'end',
['while']: 'end',
['for']: 'end',
['begin']: 'end',
['if']: 'end',
['switch']: 'end',
['"']: '"',
["'"]: "'",
['{']: '}',
['[']: ']',
['(']: ')',
} as const;
function isStartTokenType(str: string): str is startTokenType {
return ['function', 'while', 'for', 'if', 'switch', 'begin', '[', '{', '(', "'", '"'].includes(str);
}
export function findErrorCause(children: Parser.SyntaxNode[]): Parser.SyntaxNode | null {
const stack: Array<{ node: Parser.SyntaxNode; type: endTokenType; index: number; }> = [];
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (!node) continue;
if (isStartTokenType(node.type)) {
const expectedEndToken = ErrorNodeTypes[node.type];
const matchIndex = stack.findIndex(item => item.type === expectedEndToken);
if (matchIndex !== -1) {
stack.splice(matchIndex, 1); // Remove the matched end token
} else {
stack.push({ node, type: expectedEndToken, index: i }); // Push the current node and expected end token to the stack
}
} else if (Object.values(ErrorNodeTypes).includes(node.type as endTokenType)) {
stack.push({ node, type: node.type as endTokenType, index: i }); // Track all end tokens
}
}
// Lookahead logic for unclosed quote tokens
if (stack.length > 0) {
for (const item of stack) {
// Check if this is a quote token (' or ")
if (item.node.type === "'" || item.node.type === '"') {
// Look ahead to see if there are nodes after this quote that suggest it should be closed
const nodesAfterQuote = children.slice(item.index + 1);
if (hasContentAfterQuote(nodesAfterQuote)) {
return item.node; // Return this unclosed quote as the error cause
}
}
}
}
// Return the first unmatched start token from the stack, if any
return stack.length > 0 ? stack[0]?.node || null : null;
}
function hasContentAfterQuote(nodes: Parser.SyntaxNode[]): boolean {
// Check if there are meaningful nodes after the quote that suggest it should be closed
for (const node of nodes) {
// Skip whitespace and other non-meaningful nodes
if (node.type === 'escape_sequence' ||
node.type === 'word' ||
node.text.trim().length > 0) {
return true;
}
}
return false;
}
export function isExtraEnd(node: SyntaxNode) {
return node.type === 'command' && node.text === 'end';
}
export function isZeroIndex(node: SyntaxNode) {
return node.type === 'index' && node.text === '0';
}
export function isSingleQuoteVariableExpansion(node: Parser.SyntaxNode): boolean {
if (node.type !== 'single_quote_string') {
return false;
}
if (node.parent && isCommandWithName(node.parent, 'string')) {
return false;
}
const variableRegex = /(? !isOption(c) && isVariableDefinitionName(c));
if (!definitionName || !precedesRange(getRange(node), getRange(definitionName))) {
return false;
}
// check if the command is a -U/--universal option
return isMatchingOption(node, Option.create('-U', '--universal'));
}
return false;
}
export function isSourceFilename(node: SyntaxNode): boolean {
if (isSourceCommandArgumentName(node)) {
const isExisting = isExistingSourceFilenameNode(node);
if (!isExisting) {
// check if the node is a variable expansion
// if it is, do not through a diagnostic because we can't evaluate if this is a valid path
// An example of this case:
// for file in $__fish_data_dir/functions
// source $file # <--- we have no clue if this file exists
// end
if (node.type === 'variable_expansion') {
return false;
}
// also skip something like `source '$file'`
if (isString(node)) {
return false;
}
// remove `source (some_cmd a b c d)`
if (hasParent(node, (n) => n.type === 'command_substitution')) {
return false;
}
// remove `source /path/with/wildcards/*/file.fish`
if (node.text.includes('*') || node.text.includes('?')) {
return false;
}
return true;
}
return !isExisting;
}
return false;
}
export function isDotSourceCommand(node: SyntaxNode): boolean {
if (node.parent && isCommandWithName(node.parent, '.')) {
return node.parent.firstNamedChild?.equals(node) || false;
}
return false;
}
export function isTestCommandVariableExpansionWithoutString(node: SyntaxNode): boolean {
const parent = node.parent;
const previousSibling = node.previousSibling;
if (!parent || !previousSibling) return false;
if (!isCommandWithName(parent, 'test', '[')) return false;
if (isMatchingOption(previousSibling, Option.short('-n'), Option.short('-z'))) {
return !isString(node) && !!parent.child(2) && parent.child(2)!.equals(node);
}
return false;
}
function isInsideStatementCondition(statement: SyntaxNode, node: SyntaxNode): boolean {
const conditionNode = statement.childForFieldName('condition');
if (!conditionNode) return false;
return isNodeWithinOtherNode(node, conditionNode);
}
/**
* util for collecting if conditional_statement commands
* Necessary because there is two types of conditional statements:
* 1.) if cmd_1 || cmd_2; ...; end;
* 2.) if cmd_1; or cmd_2; ...; end;
* Case two is handled by the if statement, checking for the parent type of conditional_execution
* @param node - the current node to check (should be a command)
* @returns true if the node is a conditional statement, otherwise false
*/
export function isConditionalStatement(node: SyntaxNode) {
if (!node.isNamed) return false;
if (['\n', ';'].includes(node?.previousSibling?.type || '')) return false;
let curr: SyntaxNode | null = node.parent;
while (curr) {
if (curr.type === 'conditional_execution') {
curr = curr?.parent;
} else if (isIfOrElseIfConditional(curr)) {
return isInsideStatementCondition(curr, node);
} else {
break;
}
}
return false;
}
/**
* Checks if a command has a command substitution. For example,
*
* ```fish
* if set -l fishdir (status fish-path | string match -vr /bin/)
* echo $fishdir
* end
* ```
*
* @param node - the current node to check (should be a `set` command)
* @returns true if the command has a command substitution, otherwise false
*/
function hasCommandSubstitution(node: SyntaxNode) {
return node.childrenForFieldName('argument').filter(c => c.type === 'command_substitution').length > 0;
}
/**
* Get all conditional command names based on the config setting
* FIX: https://github.com/ndonfris/fish-lsp/issues/93
*/
const allConditionalCommandNames = ['command', 'type', 'set', 'string', 'abbr', 'builtin', 'functions', 'jobs'];
const getConditionalCommandNames = () => {
if (!config.fish_lsp_strict_conditional_command_warnings) {
return ['set', 'abbr', 'functions', 'jobs'];
}
return allConditionalCommandNames;
};
/**
* Command analysis utilities for functional composition
* Provides reusable command analysis operations for conditional execution logic
*/
class CommandAnalyzer {
/**
* Find the first command in a node's children
*/
static findFirstCommand(node: SyntaxNode): Maybe {
return TreeWalker.findFirstChild(node, isCommand);
}
/**
* Check if a command has quiet flags (-q, --quiet, --query)
*/
static hasQuietFlags(command: SyntaxNode): boolean {
return command.childrenForFieldName('argument')
.some(arg =>
isMatchingOption(arg, Option.create('-q', '--quiet')) ||
isMatchingOption(arg, Option.create('-q', '--query')),
);
}
/**
* Check if a command is in the list of conditional commands
*/
static isConditionalCommand(command: SyntaxNode): boolean {
return isCommandWithName(command, ...getConditionalCommandNames());
}
}
/**
* Conditional context analysis utilities
* Provides methods to analyze conditional execution contexts
*/
class ConditionalContext {
/**
* Check if a node is at the top level (direct child of program)
*/
static isTopLevel(node: SyntaxNode): boolean {
return Maybe.of(node.parent)
.map(parent => parent.type === 'program')
.getOrElse(false);
}
/**
* Check if a node is used as a condition in an if/else if statement
*/
static isUsedAsCondition(node: SyntaxNode): boolean {
return Maybe.of(node.parent)
.filter(isIfOrElseIfConditional)
.flatMap(parent => Maybe.of(parent.childForFieldName('condition')))
.equals(node);
}
/**
* Check if a node contains conditional operators (&&, ||)
*/
static hasConditionalOperators(node: SyntaxNode): boolean {
return node.text.includes('&&') || node.text.includes('||');
}
/**
* Check if a node is a conditional chain node (conditional_execution or ERROR with operators)
*/
static isConditionalChainNode(node: SyntaxNode): boolean {
return node.type === 'conditional_execution' ||
node.type === 'ERROR' && ConditionalContext.hasConditionalOperators(node);
}
}
/**
* Check if a command in a conditional context needs a -q/--quiet/--query flag
*
* This function identifies commands that are used as conditional expressions and
* should have explicit quiet flags to suppress output when used for existence checking.
*
* Rules:
* 1. In conditional_execution chains (&&, ||): only check the first command
* 2. In if/else if conditions: check the first command in the condition
* 3. Commands inside if body, nested if statements, etc. are not checked
*
* @param node - the command name node to check
* @returns true if the command needs a quiet flag, false otherwise
*/
export function isConditionalWithoutQuietCommand(node: SyntaxNode): boolean {
if (!config.fish_lsp_strict_conditional_command_warnings) {
return false;
}
return Maybe.of(node)
.filter(isCommandName)
.map(n => n.parent)
.filter(isCommand)
.filter(CommandAnalyzer.isConditionalCommand)
.filter(cmd => !isCommandWithName(cmd, 'set') || !hasCommandSubstitution(cmd))
.filter(cmd => !CommandAnalyzer.hasQuietFlags(cmd))
.map(cmd => isCommandInConditionalContext(cmd))
.getOrElse(false);
}
/**
* Determines if a command is in a conditional context where it should have quiet flags
*
* Two scenarios:
* 1. Command is the first command in a conditional_execution chain (cmd1 && cmd2 || cmd3)
* 2. Command is the first command in an if/else if condition (including nested ones)
*/
function isCommandInConditionalContext(command: SyntaxNode): boolean {
// Check if this is the first command in a conditional_execution chain
if (isFirstCommandInConditionalChain(command)) {
return true;
}
// Check if this is the first command in an if/else if condition (including nested)
if (isFirstCommandInAnyIfCondition(command)) {
return true;
}
return false;
}
/**
* Check if a command is the first command in a conditional_execution chain that is used as a test
* Examples: "set a && set -q b" at top level or in if condition - only "set a" should be flagged
* But "set a && set b" inside an if body should NOT be flagged
*/
function isFirstCommandInConditionalChain(command: SyntaxNode): boolean {
return TreeWalker.findHighest(command, ConditionalContext.isConditionalChainNode)
.filter(rootNode =>
ConditionalContext.isTopLevel(rootNode) ||
ConditionalContext.isUsedAsCondition(rootNode),
)
.flatMap(CommandAnalyzer.findFirstCommand)
.equals(command);
}
/**
* Check if a command is the first command in any if/else if condition (including nested)
* Examples: "if set a; end" or "else if set b; end" or nested "if set -q PATH; if set YARN_PATH; ..."
*/
function isFirstCommandInAnyIfCondition(command: SyntaxNode): boolean {
return TreeWalker.walkUpAll(command, isIfOrElseIfConditional)
.some(ifNode =>
Maybe.of(ifNode.childForFieldName('condition'))
.flatMap(condition => isFirstCommandInSpecificCondition(command, condition))
.getOrElse(false),
);
}
/**
* Check if a command is the first command in a specific condition node
*/
function isFirstCommandInSpecificCondition(command: SyntaxNode, conditionNode: SyntaxNode): Maybe {
// Direct command match
if (isCommand(conditionNode)) {
return Maybe.of(conditionNode.equals(command));
}
// Find first command in condition
return CommandAnalyzer.findFirstCommand(conditionNode)
.map(firstCmd => firstCmd.equals(command));
}
export function isVariableDefinitionWithExpansionCharacter(node: SyntaxNode, definedVariableExpansions: { [name: string]: SyntaxNode[]; } = {}): boolean {
if (!isVariableDefinitionName(node)) return false;
const parent = findParentCommand(node);
if (parent && isCommandWithName(parent, 'set', 'read')) {
if (!isVariableDefinitionName(node)) return false;
const name = node.text.startsWith('$') ? node.text.slice(1) : node.text;
if (!name || name.length === 0) return false;
if (definedVariableExpansions[name] && definedVariableExpansions[name]?.some(scope => isNodeWithinOtherNode(node, scope))) {
return false;
}
return node.type === 'variable_expansion' || node.text.startsWith('$');
}
return false;
}
export type LocalFunctionCallType = {
node: SyntaxNode;
text: string;
};
export function isMatchingCompleteOptionIsCommand(node: SyntaxNode) {
return isMatchingOption(node, Option.create('-n', '--condition').withValue())
|| isMatchingOption(node, Option.create('-a', '--arguments').withValue())
|| isMatchingOption(node, Option.create('-c', '--command').withValue());
}
export function isMatchingAbbrFunction(node: SyntaxNode) {
return isMatchingOption(node, Option.create('-f', '--function').withValue());
}
export function isAbbrDefinitionName(node: SyntaxNode) {
const parent = findParentCommand(node);
if (!parent) return false;
if (!isCommandWithName(parent, 'abbr')) return false;
const child = parent.childrenForFieldName('argument')
.filter(n => !isOption(n))
.find(n => n.type === 'word' && n.text !== '--' && !isString(n));
return child ? child.equals(node) : false;
}
export function isArgparseWithoutEndStdin(node: SyntaxNode) {
if (!isCommandWithName(node, 'argparse')) return false;
const endStdin = getChildNodes(node).find(n => isEndStdinCharacter(n));
if (!endStdin) return true;
return false;
}
export function isPosixCommandInsteadOfFishCommand(node: SyntaxNode): boolean {
if (!config.fish_lsp_prefer_builtin_fish_commands) return false;
if (!isCommandName(node)) {
return false;
}
const parent = findParentCommand(node);
if (!parent) return false;
if (isCommandWithName(parent, 'realpath')) {
return !parent.children.some(c => isOption(c));
}
if (isCommandWithName(parent, 'dirname', 'basename')) {
return true;
}
if (isCommandWithName(parent, 'cut', 'wc')) {
return true;
}
if (isCommandWithName(parent, 'pbcopy', 'wl-copy', 'xsel', 'xclip', 'clip.exe')) {
return true;
}
if (isCommandWithName(parent, 'pbpaste', 'wl-paste', 'xsel', 'xclip', 'clip.exe')) {
return true;
}
return false;
}
export function getFishBuiltinEquivalentCommandName(node: SyntaxNode): string | null {
if (!isPosixCommandInsteadOfFishCommand(node)) return null;
if (!isCommandName(node)) {
return null;
}
const parent = findParentCommand(node);
if (!parent) return null;
if (isCommandWithName(parent, 'dirname', 'basename')) {
return ['path', node.text].join(' ');
}
if (isCommandWithName(parent, 'realpath')) {
return 'path resolve';
}
if (isCommandWithName(parent, 'cut')) {
return 'string split';
}
if (isCommandWithName(parent, 'wc')) {
return 'count';
}
if (isCommandWithName(parent, 'pbcopy', 'wl-copy', /*'xsel', 'xclip',*/ 'clip.exe')) {
return 'fish_clipboard_copy';
}
if (isCommandWithName(parent, 'pbpaste', 'wl-paste' /*'xsel', 'xclip', 'powershell.exe'*/)) {
return 'fish_clipboard_paste';
}
if (isCommandWithName(parent, 'xsel', 'xclip')) {
return 'fish_clipboard_copy | fish_clipboard_paste';
}
return null;
}
// Returns all the autoloaded functions that do not have a `-d`/`--description` option set
export function getAutoloadedFunctionsWithoutDescription(doc: LspDocument, handler: DiagnosticCommentsHandler, allFunctions: FishSymbol[]): FishSymbol[] {
if (!doc.isAutoloaded()) return [];
return allFunctions.filter((symbol) =>
symbol.isGlobal()
&& symbol.fishKind !== 'ALIAS'
&& !symbol.node.childrenForFieldName('option').some(child => isMatchingOption(child, Option.create('-d', '--description')))
&& handler.isCodeEnabledAtNode(ErrorCodes.requireAutloadedFunctionHasDescription, symbol.node),
);
}
// callback function to check if a function is autoloaded and has an event hook
export function isFunctionWithEventHookCallback(doc: LspDocument, handler: DiagnosticCommentsHandler, allFunctions: FishSymbol[]) {
const docType = doc.getAutoloadType();
return (node: SyntaxNode): boolean => {
if (docType !== 'functions') return false;
if (!isFunctionDefinitionName(node)) return false;
if (docType === 'functions' && handler.isCodeEnabledAtNode(ErrorCodes.autoloadedFunctionWithEventHookUnused, node)) {
const funcSymbol = allFunctions.find(symbol => symbol.name === node.text);
if (funcSymbol && funcSymbol.hasEventHook()) {
const refs = getReferences(doc, funcSymbol.toPosition()).filter(ref =>
!funcSymbol.equalsLocation(ref) &&
!ref.uri.includes('completions/') &&
ref.uri !== doc.uri,
);
if (refs.length === 0) return true;
}
}
return false;
};
}
export function isFishLspDeprecatedVariableName(node: SyntaxNode): boolean {
if (isVariableDefinitionName(node)) {
return Config.isDeprecatedKey(node.text);
}
if (node.type === 'variable_name') {
return Config.isDeprecatedKey(node.text);
}
return false;
}
export function getDeprecatedFishLspMessage(node: SyntaxNode): string {
for (const [key, value] of Object.entries(Config.deprecatedKeys)) {
if (node.text === key) {
return `REPLACE \`${key}\` with \`${value}\``;
}
}
return '';
}
/**
* Check if a command name is known (builtin, function, or in completion cache)
* @param commandName - The command name to check
* @param doc - The current document for context
* @returns true if the command is known, false otherwise
*/
export function isKnownCommand(commandName: string, doc: LspDocument): boolean {
// Check if it's a builtin command
if (isBuiltin(commandName)) {
return true;
}
// Check if it's a function defined in the workspace
const globalFunctions = analyzer.globalSymbols.find(commandName);
if (globalFunctions.length > 0) {
return true;
}
// Check if it's a local function in the current document
const localSymbols = analyzer.getFlatDocumentSymbols(doc.uri);
if (localSymbols.some(s => s.isFunction() && s.name === commandName)) {
return true;
}
// Check all accessible symbols at document level (includes sourced symbols)
const allAccessibleSymbols = analyzer.allSymbolsAccessibleAtPosition(doc, { line: 0, character: 0 });
if (allAccessibleSymbols.some(s => s.isFunction() && s.name === commandName)) {
return true;
}
// Check the completion cache (includes all commands available at startup)
if (server) {
const completions = server.completions;
const commandCompletions = completions.allOfKinds(
FishCompletionItemKind.COMMAND,
FishCompletionItemKind.FUNCTION,
FishCompletionItemKind.BUILTIN,
FishCompletionItemKind.ALIAS,
// FishCompletionItemKind.ABBR,
);
if (commandCompletions.some(c => c.label === commandName)) {
return true;
}
}
return false;
}
================================================
FILE: src/diagnostics/types.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { Diagnostic } from 'vscode-languageserver-protocol';
import { ErrorCodes } from './error-codes';
import { FishSymbol } from '../parsing/symbol';
// Utilities related to building a documents Diagnostics.
/**
* Allow the node to be reachable from any Diagnostic
*/
export interface FishDiagnostic extends Diagnostic {
message: string;
range: any;
data: {
node: SyntaxNode;
fromSymbol: boolean;
};
}
export namespace FishDiagnostic {
export function create(
code: ErrorCodes.CodeTypes,
node: SyntaxNode,
message: string = '',
): FishDiagnostic {
const errorMessage = message && message.length > 0
? ErrorCodes.codes[code].message + ' | ' + message
: ErrorCodes.codes[code].message;
return {
...ErrorCodes.codes[code],
range: {
start: { line: node.startPosition.row, character: node.startPosition.column },
end: { line: node.endPosition.row, character: node.endPosition.column },
},
message: errorMessage,
data: {
node,
fromSymbol: false,
},
};
}
export function fromDiagnostic(diagnostic: Diagnostic): FishDiagnostic {
return {
...diagnostic,
data: {
node: undefined as any,
fromSymbol: false,
},
};
}
export function fromSymbol(code: ErrorCodes.CodeTypes, symbol: FishSymbol): FishDiagnostic {
const diagnostic = create(code, symbol.focusedNode);
if (code === ErrorCodes.unusedLocalDefinition) {
const localSymbolType = symbol.isVariable() ? 'variable' : 'function';
diagnostic.message += ` ${localSymbolType} '${symbol.name}' is defined but never used.`;
}
diagnostic.range = symbol.selectionRange;
diagnostic.data.fromSymbol = true;
return diagnostic;
}
}
================================================
FILE: src/diagnostics/validate.ts
================================================
import { Diagnostic, DiagnosticRelatedInformation, Range } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { LspDocument } from '../document';
import { getRange, namedNodesGen } from '../utils/tree-sitter';
import { isMatchingOption, Option } from '../parsing/options';
import { findErrorCause, isExtraEnd, isZeroIndex, isSingleQuoteVariableExpansion, isUniversalDefinition, isSourceFilename, isTestCommandVariableExpansionWithoutString, isConditionalWithoutQuietCommand, isMatchingCompleteOptionIsCommand, LocalFunctionCallType, isArgparseWithoutEndStdin, isFishLspDeprecatedVariableName, getDeprecatedFishLspMessage, isDotSourceCommand, isMatchingAbbrFunction, isFunctionWithEventHookCallback, isVariableDefinitionWithExpansionCharacter, isPosixCommandInsteadOfFishCommand, getFishBuiltinEquivalentCommandName, getAutoloadedFunctionsWithoutDescription, isWrapperFunction /*isKnownCommand*/ } from './node-types';
import { ErrorCodes } from './error-codes';
import { config } from '../config';
import { DiagnosticCommentsHandler } from './comments-handler';
import { logger } from '../logger';
import { isAutoloadedUriLoadsFunctionName, uriToReadablePath } from '../utils/translation';
import { FishString } from '../parsing/string';
import { findParent, findParentCommand, isCommandName, isCommandWithName, isComment, isCompleteCommandName, isFunctionDefinitionName, isOption, isScope, isString, isTopLevelFunctionDefinition } from '../utils/node-types';
import { isBuiltin, isReservedKeyword } from '../utils/builtins';
import { getNoExecuteDiagnostics } from './no-execute-diagnostic';
import { checkForInvalidDiagnosticCodes } from './invalid-error-code';
import { analyzer } from '../analyze';
import { FishSymbol } from '../parsing/symbol';
import { findUnreachableCode } from '../parsing/unreachable';
import { allUnusedLocalReferences } from '../references';
import { FishDiagnostic } from './types';
import { server } from '../server';
import { FishCompletionItemKind } from '../utils/completion/types';
// Number of nodes to process before yielding to event loop
const CHUNK_SIZE = 100;
/**
* Async version of getDiagnostics that yields to the event loop periodically
* to avoid blocking the main thread during diagnostic calculation.
*
* This function has identical behavior to getDiagnostics(), but processes
* nodes in chunks and yields between chunks using setImmediate().
*
* @param root - The root syntax node of the document
* @param doc - The LspDocument being analyzed
* @param signal - Optional AbortSignal to cancel the computation
* @param maxDiagnostics - Optional limit on number of diagnostics to return (0 = unlimited)
* @returns Promise resolving to array of diagnostics
*/
export async function getDiagnosticsAsync(
root: SyntaxNode,
doc: LspDocument,
signal?: AbortSignal,
maxDiagnostics: number = config.fish_lsp_max_diagnostics,
): Promise {
const diagnostics: Diagnostic[] = [];
// Helper to check if we've hit the diagnostic limit
const hasReachedLimit = () => maxDiagnostics > 0 && diagnostics.length >= maxDiagnostics;
// Helper to add diagnostics and check if limit was reached
const addDiagnostics = (...diags: Diagnostic[]): boolean => {
diagnostics.push(...diags);
return hasReachedLimit();
};
const handler = new DiagnosticCommentsHandler();
const isAutoloadedFunctionName = isAutoloadedUriLoadsFunctionName(doc);
const docType = doc.getAutoloadType();
// arrays to keep track of different groups of functions
const allFunctions: FishSymbol[] = analyzer.getFlatDocumentSymbols(doc.uri).filter(s => s.isFunction());
const autoloadedFunctions: SyntaxNode[] = [];
const topLevelFunctions: SyntaxNode[] = [];
const functionsWithReservedKeyword: SyntaxNode[] = [];
const localFunctions: SyntaxNode[] = [];
const localFunctionCalls: LocalFunctionCallType[] = [];
const commandNames: SyntaxNode[] = [];
const completeCommandNames: SyntaxNode[] = [];
// handles and returns true/false if the node is a variable definition with an expansion character
const definedVariables: { [name: string]: SyntaxNode[]; } = {};
// callback to check if the function has an `--event` handler && the handler is enabled at the node
const isFunctionWithEventHook = isFunctionWithEventHookCallback(doc, handler, allFunctions);
// Process nodes in chunks to avoid blocking the main thread
// Using generator for better memory efficiency
let i = 0;
for (const node of namedNodesGen(root)) {
// Check if computation was cancelled
if (signal?.aborted) {
logger.warning('Diagnostic computation cancelled');
return diagnostics;
}
// Early exit if we've hit the diagnostic limit
if (hasReachedLimit()) {
return diagnostics;
}
handler.handleNode(node);
// Check for invalid diagnostic codes first
const invalidDiagnosticCodes = checkForInvalidDiagnosticCodes(node);
if (invalidDiagnosticCodes.length > 0) {
// notice, this is the only case where we don't check if the user has disabled the error code
// because `# @fish-lsp-disable` will always be recognized as a disabled error code
if (addDiagnostics(...invalidDiagnosticCodes)) return diagnostics;
}
if (isComment(node)) {
continue;
}
if (node.type === 'variable_name' || node.text.startsWith('$') || isString(node)) {
const parent = findParentCommand(node);
if (parent && isCommandWithName(parent, 'set', 'test')) {
const opt = isCommandWithName(parent, 'test') ? Option.short('-n') : Option.create('-q', '--query');
let text = FishString.fromNode(node);
if (text.startsWith('$')) text = text.slice(1);
if (text && text.length !== 0) {
const scope = findParent(node, n => isScope(n));
if (scope && parent.children.some(c => isMatchingOption(c, opt))) {
definedVariables[text] = definedVariables[text] || [];
definedVariables[text]?.push(scope);
}
}
}
}
if (node.isError) {
const found: SyntaxNode | null = findErrorCause(node.children);
if (found && handler.isCodeEnabled(ErrorCodes.missingEnd)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.missingEnd, node))) return diagnostics;
}
}
if (isExtraEnd(node) && handler.isCodeEnabled(ErrorCodes.extraEnd)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.extraEnd, node))) return diagnostics;
}
if (isZeroIndex(node) && handler.isCodeEnabled(ErrorCodes.missingEnd)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.zeroIndexedArray, node))) return diagnostics;
}
if (isSingleQuoteVariableExpansion(node) && handler.isCodeEnabled(ErrorCodes.singleQuoteVariableExpansion)) {
// don't add this diagnostic if the autoload type is completions
if (doc.getAutoloadType() !== 'completions') {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.singleQuoteVariableExpansion, node))) return diagnostics;
}
}
if (isWrapperFunction(node, handler)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.usedWrapperFunction, node))) return diagnostics;
}
if (isUniversalDefinition(node) && docType !== 'conf.d' && handler.isCodeEnabled(ErrorCodes.usedUnviersalDefinition)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.usedUnviersalDefinition, node))) return diagnostics;
}
if (isPosixCommandInsteadOfFishCommand(node) && handler.isCodeEnabled(ErrorCodes.usedExternalShellCommandWhenBuiltinExists)) {
const diagnostic = FishDiagnostic.create(
ErrorCodes.usedExternalShellCommandWhenBuiltinExists,
node,
`Use the Fish builtin command '${getFishBuiltinEquivalentCommandName(node)!}' instead of the external shell command.`,
);
if (addDiagnostics(diagnostic)) return diagnostics;
}
if (isSourceFilename(node) && handler.isCodeEnabled(ErrorCodes.sourceFileDoesNotExist)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.sourceFileDoesNotExist, node))) return diagnostics;
}
if (isDotSourceCommand(node) && handler.isCodeEnabled(ErrorCodes.dotSourceCommand)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.dotSourceCommand, node))) return diagnostics;
}
if (isTestCommandVariableExpansionWithoutString(node) && handler.isCodeEnabled(ErrorCodes.testCommandMissingStringCharacters)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.testCommandMissingStringCharacters, node))) return diagnostics;
}
if (isConditionalWithoutQuietCommand(node) && handler.isCodeEnabled(ErrorCodes.missingQuietOption)) {
logger.log('isConditionalWithoutQuietCommand', { type: node.type, text: node.text });
const command = node.firstNamedChild || node;
let subCommand = command;
if (command.text.includes('string')) {
subCommand = command.nextSibling || node.nextSibling!;
}
const range: Range = {
start: { line: command.startPosition.row, character: command.startPosition.column },
end: { line: subCommand.endPosition.row, character: subCommand.endPosition.column },
};
if (addDiagnostics({
...FishDiagnostic.create(ErrorCodes.missingQuietOption, node),
range,
})) return diagnostics;
}
if (isArgparseWithoutEndStdin(node) && handler.isCodeEnabled(ErrorCodes.argparseMissingEndStdin)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.argparseMissingEndStdin, node))) return diagnostics;
}
// store the defined variable expansions and then use them in the next check
if (isVariableDefinitionWithExpansionCharacter(node, definedVariables) && handler.isCodeEnabled(ErrorCodes.dereferencedDefinition)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.dereferencedDefinition, node))) return diagnostics;
}
if (isFishLspDeprecatedVariableName(node) && handler.isCodeEnabled(ErrorCodes.fishLspDeprecatedEnvName)) {
logger.log('isFishLspDeprecatedVariableName', doc.getText(getRange(node)));
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.fishLspDeprecatedEnvName, node, getDeprecatedFishLspMessage(node)))) return diagnostics;
}
/** store any functions we see, to reuse later */
if (isFunctionDefinitionName(node)) {
if (isAutoloadedFunctionName(node)) autoloadedFunctions.push(node);
if (isTopLevelFunctionDefinition(node)) topLevelFunctions.push(node);
if (isReservedKeyword(node.text)) functionsWithReservedKeyword.push(node);
if (!isAutoloadedFunctionName(node)) localFunctions.push(node);
if (isFunctionWithEventHook(node)) {
// TODO: add support for `emit` to reference the event hook
if (addDiagnostics(
FishDiagnostic.create(
ErrorCodes.autoloadedFunctionWithEventHookUnused,
node,
`Function '${node.text}' has an event hook but is not called anywhere in the workspace.`,
),
)) return diagnostics;
}
}
// skip options like: '-f'
if (isOption(node)) {
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
continue;
}
/** keep this section at end of loop iteration, because it uses continue */
if (isCommandName(node)) commandNames.push(node);
// get the parent and previous sibling, for the next checks
const { parent, previousSibling } = node;
if (!parent || !previousSibling) {
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
continue;
}
// skip if this is an abbr function, since we don't want to complete abbr functions
if (isCommandWithName(parent, 'abbr') && isMatchingAbbrFunction(previousSibling)) {
localFunctionCalls.push({ node, text: node.text });
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
continue;
}
// if the current node is a bind subcommand `bind ctrl-k ` where `` gets added to the localFunctionCalls
if (isCommandWithName(parent, 'bind')) {
const subcommands = parent.children.slice(2).filter(c => !isOption(c));
subcommands.forEach(subcommand => {
if (isString(subcommand)) {
// like this example: `(cmd; and cmd2)`
// we remove the characters: `( ; and )`
localFunctionCalls.push({
node,
text: subcommand.text.slice(1, -1)
.replace(/[\(\)]/g, '') // Remove parentheses
.replace(/[^\u0020-\u007F]/g, ''), // Keep only ASCII printable chars
});
return;
}
localFunctionCalls.push({ node, text: subcommand.text });
});
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
continue;
}
// for autoloaded files that could have completions, we only want to check for `complete` commands
if (doc.isAutoloadedWithPotentialCompletions()) {
if (isCompleteCommandName(node)) completeCommandNames.push(node);
// skip if no parent command (note we already added commands above)
if (!isCommandWithName(parent, 'complete')) {
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
continue;
}
// skip if no previous sibling (since we're looking for `complete -n/-a/-c `)
if (isMatchingCompleteOptionIsCommand(previousSibling)) {
// if we find a string, remove unnecessary tokens from arguments
if (isString(node)) {
// like this example: `(cmd; and cmd2)`
// we remove the characters: `( ; and )`
localFunctionCalls.push({
node,
text: node.text.slice(1, -1)
.replace(/[\(\)]/g, '') // Remove parentheses
.replace(/[^\u0020-\u007F]/g, ''), // Keep only ASCII printable chars
});
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
continue;
}
// otherwise, just add the node as is (should just be an unquoted command)
localFunctionCalls.push({ node, text: node.text });
}
}
// Yield to event loop every CHUNK_SIZE iterations
if (++i % CHUNK_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve));
}
}
// Check if computation was cancelled before post-processing
if (signal?.aborted) {
logger.warning('Diagnostic computation cancelled');
return diagnostics;
}
// Skip post-processing if we've already hit the diagnostic limit
if (hasReachedLimit()) {
return diagnostics;
}
// allow nodes outside of the loop, to retrieve the old state
handler.finalizeStateMap(root.text.split('\n').length + 1);
const isMissingAutoloadedFunction = docType === 'functions'
? autoloadedFunctions.length === 0
: false;
const isMissingAutoloadedFunctionButContainsOtherFunctions =
isMissingAutoloadedFunction && topLevelFunctions.length > 0;
// no function definition for autoloaded function file
if (isMissingAutoloadedFunction && topLevelFunctions.length === 0 && handler.isCodeEnabledAtNode(ErrorCodes.autoloadedFunctionMissingDefinition, root)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.autoloadedFunctionMissingDefinition, root))) return diagnostics;
}
// has functions/file.fish has top level functions, but none match the filename
if (isMissingAutoloadedFunctionButContainsOtherFunctions) {
topLevelFunctions.forEach(node => {
if (handler.isCodeEnabledAtNode(ErrorCodes.autoloadedFunctionFilenameMismatch, node)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.autoloadedFunctionFilenameMismatch, node))) return diagnostics;
}
});
}
// has functions with invalid names -- (reserved keywords)
functionsWithReservedKeyword.forEach(node => {
if (handler.isCodeEnabledAtNode(ErrorCodes.functionNameUsingReservedKeyword, node)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.functionNameUsingReservedKeyword, node))) return diagnostics;
}
});
// get all function definitions in the document
const duplicateFunctions: { [name: string]: FishSymbol[]; } = {};
allFunctions.forEach(node => {
const currentDupes = duplicateFunctions[node.name] ?? [];
currentDupes.push(node);
duplicateFunctions[node.name] = currentDupes;
});
// Add diagnostics for duplicate function definitions in the same scope
Object.entries(duplicateFunctions).forEach(([_, functionSymbols]) => {
// skip single function definitions
if (functionSymbols.length <= 1) return;
functionSymbols.forEach(n => {
if (handler.isCodeEnabledAtNode(ErrorCodes.duplicateFunctionDefinitionInSameScope, n.focusedNode)) {
// dupes are the array of all function symbols that have the same name and scope as the current symbol `n`
const dupes = functionSymbols.filter(s => s.scopeNode.equals(n.scopeNode) && !s.equals(n)) ?? [] as FishSymbol[];
// skip if the function is defined in a different scope
if (dupes.length < 1) return;
// create a diagnostic for the duplicate function definition
const diagnostic = FishDiagnostic.create(ErrorCodes.duplicateFunctionDefinitionInSameScope, n.focusedNode);
diagnostic.range = n.selectionRange;
// plus one because the dupes array does not include the current symbol `n`
diagnostic.message += ` '${n.name}' is defined ${dupes.length + 1} time(s) in ${n.scopeTag.toUpperCase()} scope.`;
diagnostic.message += `\n\nFILE: ${uriToReadablePath(n.uri)}`;
// diagnostic.data.symbol = n;
diagnostic.relatedInformation = dupes.filter(s => !s.equals(n)).map(s => DiagnosticRelatedInformation.create(
s.toLocation(),
`${s.scopeTag.toUpperCase()} duplicate '${s.name}' defined on line ${s.focusedNode.startPosition.row}`,
));
if (addDiagnostics(diagnostic)) return diagnostics;
}
});
});
// `4008` -> auto-loaded functions without description
getAutoloadedFunctionsWithoutDescription(doc, handler, allFunctions).forEach((symbol) => {
if (addDiagnostics(FishDiagnostic.fromSymbol(ErrorCodes.requireAutloadedFunctionHasDescription, symbol))) return diagnostics;
});
localFunctions.forEach(node => {
const matches = commandNames.filter(call => call.text === node.text);
if (matches.length === 0) return;
if (!localFunctionCalls.some(call => call.text === node.text)) {
localFunctionCalls.push({ node, text: node.text });
}
});
const docNameMatchesCompleteCommandNames = completeCommandNames.some(node =>
FishString.fromNode(node) === doc.getAutoLoadName());
// if no `complete -c func_name` matches the autoload name
if (completeCommandNames.length > 0 && !docNameMatchesCompleteCommandNames && doc.isAutoloadedCompletion()) {
const completeNames: Set = new Set();
for (const completeCommandName of completeCommandNames) {
if (!completeNames.has(FishString.fromNode(completeCommandName)) && handler.isCodeEnabledAtNode(ErrorCodes.autoloadedCompletionMissingCommandName, completeCommandName)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.autoloadedCompletionMissingCommandName, completeCommandName, completeCommandName.text))) return diagnostics;
completeNames.add(FishString.fromNode(completeCommandName));
}
}
}
// 4004 -> unused local function/variable definitions
if (handler.isRootEnabled(ErrorCodes.unusedLocalDefinition)) {
const unusedLocalDefinitions = allUnusedLocalReferences(doc);
for (const unusedLocalDefinition of unusedLocalDefinitions) {
// skip definitions that do not need local references
if (!unusedLocalDefinition.needsLocalReferences()) {
logger.debug('Skipping unused local definition', {
name: unusedLocalDefinition.name,
uri: unusedLocalDefinition.uri,
type: unusedLocalDefinition.kind,
});
continue;
}
if (handler.isCodeEnabledAtNode(ErrorCodes.unusedLocalDefinition, unusedLocalDefinition.focusedNode)) {
if (addDiagnostics(
FishDiagnostic.fromSymbol(ErrorCodes.unusedLocalDefinition, unusedLocalDefinition),
)) return diagnostics;
}
}
}
// 5555 -> code is not reachable
if (handler.isRootEnabled(ErrorCodes.unreachableCode)) {
const unreachableNodes = findUnreachableCode(root);
for (const unreachableNode of unreachableNodes) {
if (handler.isCodeEnabledAtNode(ErrorCodes.unreachableCode, unreachableNode)) {
if (addDiagnostics(FishDiagnostic.create(ErrorCodes.unreachableCode, unreachableNode))) return diagnostics;
}
}
}
// 7001 -> unknown command
if (handler.isRootEnabled(ErrorCodes.unknownCommand)) {
// Cache expensive lookups that are reused for every command
const knownCommandsCache = new Set();
const unknownCommandsCache = new Set();
// Pre-compute expensive lookups once
const localSymbols = analyzer.getFlatDocumentSymbols(doc.uri);
const localFunctionNames = new Set(localSymbols.filter(s => s.isFunction()).map(s => s.name));
const allAccessibleSymbols = analyzer.allReachableSymbols(doc.uri);
// Pre-load completion cache if available
let commandCompletions: Set | null = null;
if (server) {
const completions = server.completions;
const commandCompletionList = completions.allOfKinds(
FishCompletionItemKind.ALIAS,
FishCompletionItemKind.BUILTIN,
FishCompletionItemKind.FUNCTION,
FishCompletionItemKind.COMMAND,
);
commandCompletions = new Set(commandCompletionList.map(c => c.label));
}
for (const commandNode of commandNames) {
const commandName = commandNode.text.trim();
// Skip empty commands or commands that are already errors
if (!commandName || commandNode.isError) {
continue;
}
if (!handler.isCodeEnabledAtNode(ErrorCodes.unknownCommand, commandNode)) {
continue;
}
// Skip commands that are actually relative paths (start with '.')
if (commandName.startsWith('.') || commandName.includes('/')) {
continue;
}
// Check cache first
if (knownCommandsCache.has(commandName)) {
continue;
}
if (unknownCommandsCache.has(commandName)) {
if (handler.isCodeEnabledAtNode(ErrorCodes.unknownCommand, commandNode)) {
if (addDiagnostics(
FishDiagnostic.create(
ErrorCodes.unknownCommand,
commandNode,
`'${commandName}' is not a known builtin, function, or command`,
),
)) return diagnostics;
}
continue;
}
// Check if command is known (using cached data)
let isKnown = false;
// Check builtins (fast)
if (isBuiltin(commandName)) {
isKnown = true;
} else if (localFunctionNames.has(commandName)) {
// Check local functions (cached)
isKnown = true;
} else if (allAccessibleSymbols.some(s => s.name === commandName)) {
// Check accessible functions (cached)
isKnown = true;
} else if (analyzer.globalSymbols.find(commandName).length > 0) {
// Check global symbols
isKnown = true;
} else if (commandCompletions && commandCompletions.has(commandName)) {
// Check completion cache (cached)
isKnown = true;
}
// Update cache
if (isKnown) {
knownCommandsCache.add(commandName);
} else {
unknownCommandsCache.add(commandName);
if (handler.isCodeEnabledAtNode(ErrorCodes.unknownCommand, commandNode)) {
if (addDiagnostics(
FishDiagnostic.create(
ErrorCodes.unknownCommand,
commandNode,
`'${commandName}' is not a known builtin, function, or command`,
),
)) return diagnostics;
}
}
}
}
// add 9999 diagnostics from `fish --no-execute` if the user enabled it
if (config.fish_lsp_enable_experimental_diagnostics) {
const noExecuteDiagnostics = getNoExecuteDiagnostics(doc);
for (const diagnostic of noExecuteDiagnostics) {
if (handler.isCodeEnabledAtNode(ErrorCodes.syntaxError, diagnostic.data.node)) {
if (addDiagnostics(diagnostic)) return diagnostics;
}
}
}
return diagnostics;
}
================================================
FILE: src/document-highlight.ts
================================================
import { Analyzer } from './analyze';
import { getRange } from './utils/tree-sitter';
import { DocumentHighlight, DocumentHighlightKind, DocumentHighlightParams, Location } from 'vscode-languageserver';
import { isCommandName } from './utils/node-types';
import { LspDocument } from './document';
import { getReferences } from './references';
import { isBuiltin } from './utils/builtins';
/**
* TODO:
* ADD DocumentHighlightKind.Read | DocumentHighlightKind.Write support
*/
export function getDocumentHighlights(analyzer: Analyzer) {
function convertSymbolLocationsToHighlights(doc: LspDocument, locations: Location[]): DocumentHighlight[] {
return locations
.filter(loc => loc.uri === doc.uri)
.map(loc => {
return {
range: loc.range,
kind: DocumentHighlightKind.Text,
};
});
}
return function(params: DocumentHighlightParams): DocumentHighlight[] {
const { uri } = params.textDocument;
const { line, character } = params.position;
const doc = analyzer.getDocument(uri);
if (!doc) return [];
const word = analyzer.wordAtPoint(uri, line, character);
if (!word || word.trim() === '') return [];
const nodes = analyzer.getNodes(uri);
// check if the word is a builtin function
if (isBuiltin(word)) {
return nodes
.filter(n => isBuiltin(n.text) && n.text === word)
.map(n => {
return {
range: getRange(n),
kind: DocumentHighlightKind.Text,
};
});
}
const symbol = analyzer.getDefinition(doc, params.position);
const node = analyzer.nodeAtPoint(uri, line, character);
if (!node || !node.isNamed) return [];
// check if a node is a command name
if (!symbol && isCommandName(node)) {
const matchingCommandNodes =
nodes.filter(n => isCommandName(n) && n.text === node.text);
return matchingCommandNodes.map(n => {
return {
range: getRange(n),
kind: DocumentHighlightKind.Text,
};
});
}
// use local symbol reference locations
if (symbol) {
const refLocations = getReferences(doc, symbol.selectionRange.start, { localOnly: true });
if (!refLocations) return [];
return convertSymbolLocationsToHighlights(doc, refLocations);
}
return [];
};
}
================================================
FILE: src/document.ts
================================================
import * as path from 'path';
import { homedir } from 'os';
import { promises } from 'fs';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { Position, Range, TextDocumentItem, TextDocumentContentChangeEvent, VersionedTextDocumentIdentifier, TextDocumentIdentifier, DocumentUri } from 'vscode-languageserver';
import { TextDocuments } from 'vscode-languageserver/node';
import { workspaceManager } from './utils/workspace-manager';
import { AutoloadType, isPath, isTextDocument, isTextDocumentItem, isUri, PathLike, pathToUri, uriToPath } from './utils/translation';
import { Workspace } from './utils/workspace';
import { SyncFileHelper } from './utils/file-operations';
import { logger } from './logger';
import * as Locations from './utils/locations';
import { FishSymbol } from './parsing/symbol';
import { logTreeSitterDocumentDebug, returnParseTreeString } from './utils/cli-dump-tree';
export class LspDocument implements TextDocument {
protected document: TextDocument;
public lastChangedLineSpan?: LineSpan;
constructor(doc: TextDocumentItem) {
const { uri, languageId, version, text } = doc;
this.document = TextDocument.create(uri, languageId, version, text);
this.lastChangedLineSpan = computeChangedLineSpan([{ text }]);
}
static createTextDocumentItem(uri: string, text: string): LspDocument {
return new LspDocument({
uri,
languageId: 'fish',
version: 1,
text,
});
}
static fromTextDocument(doc: TextDocument): LspDocument {
const item = TextDocumentItem.create(doc.uri, doc.languageId, doc.version, doc.getText());
return new LspDocument(item);
}
static createFromUri(uri: DocumentUri): LspDocument {
const content = SyncFileHelper.read(uriToPath(uri));
return LspDocument.createTextDocumentItem(uri, content);
}
static createFromPath(path: PathLike): LspDocument {
const content = SyncFileHelper.read(path);
return LspDocument.createTextDocumentItem(pathToUri(path), content);
}
static testUri(uri: DocumentUri): string {
const removeString = 'tests/workspaces';
if (uri.includes(removeString)) {
return 'file:///…/' + uri.slice(uri.indexOf(removeString) + removeString.length + 1);
}
return uri;
}
static testUtil(uri: DocumentUri) {
const shortUri = LspDocument.testUri(uri);
const fullPath = uriToPath(uri);
const parentDir = path.dirname(fullPath);
const relativePath = shortUri.slice(shortUri.indexOf(parentDir) + parentDir.length + 1);
return {
uri,
shortUri,
fullPath,
relativePath,
parentDir,
};
}
static create(
uri: string,
languageId: string,
version: number,
text: string,
): LspDocument {
const inner = TextDocument.create(uri, languageId, version, text);
return new LspDocument({ uri: inner.uri, languageId: inner.languageId, version: inner.version, text: inner.getText() });
}
static update(
doc: LspDocument,
changes: TextDocumentContentChangeEvent[],
version: number,
): LspDocument {
doc.document = TextDocument.update(doc.document, changes, version);
doc.lastChangedLineSpan = computeChangedLineSpan(changes);
return doc;
}
/**
* Creates a new LspDocument from a path, URI, TextDocument, TextDocumentItem, or another LspDocument.
* @param param The parameter to create the LspDocument from.
* @returns A new LspDocument instance.
*/
static createFrom(uri: DocumentUri): LspDocument;
static createFrom(path: PathLike): LspDocument;
static createFrom(doc: TextDocument): LspDocument;
static createFrom(doc: TextDocumentItem): LspDocument;
static createFrom(doc: LspDocument): LspDocument;
static createFrom(param: PathLike | DocumentUri | TextDocument | TextDocumentItem | LspDocument): LspDocument;
static createFrom(param: PathLike | DocumentUri | TextDocument | TextDocumentItem | LspDocument): LspDocument {
if (typeof param === 'string' && isPath(param)) return LspDocument.createFromPath(param);
if (typeof param === 'string' && isUri(param)) return LspDocument.createFromUri(param);
if (LspDocument.is(param)) return LspDocument.fromTextDocument(param.document);
if (isTextDocumentItem(param)) return LspDocument.createTextDocumentItem(param.uri, param.text);
if (isTextDocument(param)) return LspDocument.fromTextDocument(param);
// we should never reach here
logger.error('Invalid parameter type `LspDocument.create()`: ', param);
return undefined as never;
}
static async createFromUriAsync(uri: DocumentUri): Promise {
const content = await promises.readFile(uriToPath(uri), 'utf8');
return LspDocument.createTextDocumentItem(uri, content);
}
asTextDocumentItem(): TextDocumentItem {
return {
uri: this.document.uri,
languageId: this.document.languageId,
version: this.document.version,
text: this.document.getText(),
};
}
asTextDocumentIdentifier(): TextDocumentIdentifier {
return {
uri: this.document.uri,
};
}
get uri(): DocumentUri {
return this.document.uri;
}
get languageId(): string {
return this.document.languageId;
}
get version(): number {
return this.document.version;
}
get path(): string {
return uriToPath(this.document.uri);
}
/**
* Fallback span that covers the entire document
*/
get fullSpan() {
return {
start: 0,
end: this.positionAt(this.getText().length).line,
};
}
getText(range?: Range): string {
return this.document.getText(range);
}
positionAt(offset: number): Position {
return this.document.positionAt(offset);
}
offsetAt(position: Position): number {
return this.document.offsetAt(position);
}
get lineCount(): number {
return this.document.lineCount;
}
create(uri: string, languageId: string, version: number, text: string): LspDocument {
return new LspDocument({
uri,
languageId: languageId || 'fish',
version: version || 1,
text,
});
}
/**
* @see getLineBeforeCursor()
*/
getLine(line: number | Position | Range | FishSymbol): string {
if (Locations.Position.is(line)) {
line = line.line;
} else if (Locations.Range.is(line)) {
line = line.start.line;
} else if (FishSymbol.is(line)) {
line = line.range.start.line;
}
const lines = this.document.getText().split('\n');
return lines[line] || '';
}
getLineBeforeCursor(position: Position): string {
const lineStart = Position.create(position.line, 0);
const lineEnd = Position.create(position.line, position.character);
const lineRange = Range.create(lineStart, lineEnd);
return this.getText(lineRange);
}
getLineRange(line: number): Range {
const lineStart = this.getLineStart(line);
const lineEnd = this.getLineEnd(line);
return Range.create(lineStart, lineEnd);
}
getLineEnd(line: number): Position {
const nextLineOffset = this.getLineOffset(line + 1);
return this.positionAt(nextLineOffset - 1);
}
getLineOffset(line: number): number {
const lineStart = this.getLineStart(line);
return this.offsetAt(lineStart);
}
getLineStart(line: number): Position {
return Position.create(line, 0);
}
getIndentAtLine(line: number): string {
const lineText = this.getLine(line);
const indent = lineText.match(/^\s+/);
return indent ? indent[0] : '';
}
/**
* Apply incremental LSP changes to this document.
*
* @param changes TextDocumentContentChangeEvent[] from textDocument/didChange
* @param version Optional LSP version; if omitted, increments current version
*/
update(changes: TextDocumentContentChangeEvent[], version?: number): void {
const newVersion = version ?? this.version + 1;
this.document = TextDocument.update(this.document, changes, newVersion);
}
asVersionedIdentifier() {
return VersionedTextDocumentIdentifier.create(this.uri, this.version);
}
rename(newUri: string): void {
this.document = TextDocument.create(newUri, this.languageId, this.version, this.getText());
}
getFilePath(): string {
return uriToPath(this.uri);
}
getFilename(): string {
return this.uri.split('/').pop() as string;
}
getRelativeFilenameToWorkspace(): string {
const home = homedir();
const path = this.uri.replace(home, '~');
const dirs = path.split('/');
if (this.isCommandlineBuffer() || this.isFunced()) {
return this.path.split('/').pop() as string;
}
const workspaceRootIndex = dirs.find(dir => dir === 'fish')
? dirs.indexOf('fish')
: dirs.find(dir => ['conf.d', 'functions', 'completions', 'config.fish'].includes(dir))
// ? dirs.findLastIndex(dir => ['conf.d', 'functions', 'completions', 'config.fish'].includes(dir))
? dirs.findIndex(dir => ['conf.d', 'functions', 'completions', 'config.fish'].includes(dir))
: dirs.length - 1;
return dirs.slice(workspaceRootIndex).join('/');
}
/**
* checks if the functions are defined in a functions directory
*/
isFunction(): boolean {
const pathArray = this.uri.split('/');
const fileName = pathArray.pop();
const parentDir = pathArray.pop();
/** paths that autoload all top level functions to the shell env */
if (parentDir === 'conf.d' || fileName === 'config.fish') {
return true;
}
/** path that autoload matching filename functions to the shell env */
return parentDir === 'functions';
}
isAutoloadedFunction(): boolean {
return this.getAutoloadType() === 'functions';
}
isAutoloadedCompletion(): boolean {
return this.getAutoloadType() === 'completions';
}
isAutoloadedConfd(): boolean {
return this.getAutoloadType() === 'conf.d';
}
shouldAnalyzeInBackground(): boolean {
const pathArray = this.uri.split('/');
const fileName = pathArray.pop();
const parentDir = pathArray.pop();
return parentDir && ['functions', 'conf.d', 'completions'].includes(parentDir?.toString()) || fileName === 'config.fish';
}
public getWorkspace(): Workspace | undefined {
return workspaceManager.findContainingWorkspace(this.uri) || undefined;
}
private getFolderType(): AutoloadType | null {
const docPath = uriToPath(this.uri);
if (!docPath) return null;
// Treat funced files as if they were in the functions directory
if (this.isFunced()) return 'functions';
if (this.isCommandlineBuffer()) return 'conf.d';
const dirName = path.basename(path.dirname(docPath));
const fileName = path.basename(docPath);
if (dirName === 'functions') return 'functions';
if (dirName === 'conf.d') return 'conf.d';
if (dirName === 'completions') return 'completions';
if (fileName === 'config.fish') return 'config';
return '';
}
/**
* checks if the document is in a location where the functions
* that it defines are autoloaded by fish.
*
* Use isAutoloadedUri() if you want to check for completions
* files as well. This function does not check for completion
* files.
*/
isAutoloaded(): boolean {
const folderType = this.getFolderType();
if (!folderType) return false;
if (this.isFunced()) return true;
return ['functions', 'conf.d', 'config'].includes(folderType);
}
isFunced(): boolean {
return LspDocument.isFuncedPath(this.path);
}
isCommandlineBuffer(): boolean {
return LspDocument.isCommandlineBufferPath(this.path);
}
static isFuncedPath(path: string): boolean {
return path.startsWith('/tmp/fish-funced.');
}
static isCommandlineBufferPath(path: string): boolean {
return path.startsWith('/tmp/fish.') && path.endsWith('command-line.fish');
}
/**
* checks if the document is in a location:
* - `fish/{conf.d,functions,completions}/file.fish`
* - `fish/config.fish`
*
* Key difference from isAutoLoaded is that this function checks for
* completions files as well. isAutoloaded() does not check for
* completion files.
*/
isAutoloadedUri(): boolean {
const folderType = this.getFolderType();
if (!folderType) return false;
return ['functions', 'conf.d', 'config', 'completions'].includes(folderType);
}
/**
* checks if the document is in a location where it is autoloaded
* @returns {boolean} - true if the document is in a location that could contain `complete` definitions
*/
isAutoloadedWithPotentialCompletions(): boolean {
const folderType = this.getFolderType();
if (!folderType) return false;
return ['conf.d', 'config', 'completions'].includes(folderType);
}
/**
* helper that gets the document URI if it is fish/functions directory
*/
getAutoloadType(): AutoloadType {
return this.getFolderType() || '';
}
/**
* helper that gets the document URI if it is fish/functions directory
* @returns {string} - what the function name should be, or '' if it is not autoloaded
*/
getAutoLoadName(): string {
if (!this.isAutoloadedUri()) {
return '';
}
const parts = uriToPath(this.uri)?.split('/') || [];
const name = parts[parts.length - 1];
return name!.replace('.fish', '');
}
getFileName(): string {
const items = uriToPath(this.uri).split('/') || [];
const name = items.length > 0 ? items.pop()! : uriToPath(this.uri);
return name;
}
getLines(): number {
const lines = this.getText().split('\n');
return lines.length;
}
showTree(): void {
logTreeSitterDocumentDebug(this);
}
getTree(): string {
return returnParseTreeString(this);
}
updateVersion(version: number) {
this.document = this.create(this.document.uri, this.document.languageId, version, this.document.getText());
return this;
}
/**
* Type guard to check if an object is an LspDocument
*
* @param value The value to check
* @returns True if the value is an LspDocument, false otherwise
*/
static is(value: unknown): value is LspDocument {
return (
// Check if it's an object first
typeof value === 'object' &&
value !== null &&
// Check for LspDocument-specific methods/properties not found in TextDocument or TextDocumentItem
typeof (value as LspDocument).asTextDocumentItem === 'function' &&
typeof (value as LspDocument).asTextDocumentIdentifier === 'function' &&
typeof (value as LspDocument).getAutoloadType === 'function' &&
typeof (value as LspDocument).isAutoloaded === 'function' &&
typeof (value as LspDocument).path === 'string' &&
typeof (value as LspDocument).getFileName === 'function' &&
typeof (value as LspDocument).getRelativeFilenameToWorkspace === 'function' &&
typeof (value as LspDocument).getLine === 'function' &&
typeof (value as LspDocument).getLines === 'function' &&
// Ensure base TextDocument properties are also present
typeof (value as LspDocument).uri === 'string' &&
typeof (value as LspDocument).getText === 'function'
);
}
/**
* @TODO check that this correctly handles range creation for both starting and ending positions
* If this doesn't work as expected, we could alternatively create the range manually with
* `getRange(analyzedDocument.root)`
*/
get fileRange(): Range {
const start = Position.create(0, 0);
const end = this.positionAt(this.getText().length);
return Range.create(start, end);
}
hasShebang(): boolean {
const firstLine = this.getLine(0);
return firstLine.startsWith('#!');
}
}
/**
* A LineSpan represents a range of lines in a document that have changed.
*
* We use this later to optimize diagnostic updates, by comparing the changed
* line span to the ranges of existing diagnostics, and removing any that
* fall within the changed span.
*
* @property start - The starting line number (0-based).
* @property end - The ending line number (0-based).
* @property isFullDocument - If true, indicates the entire document changed.
*
* isFullDocument is optional and defaults to false, but is useful because
* the consumer of this type, might want to treat actual isFullDocument changes
* differently than incremental changes that would happen `documents.onDidChangeContent()`
*/
export type LineSpan = { start: number; end: number; isFullDocument?: boolean; };
/**
* Computes the span of lines that have changed in a set of TextDocumentContentChangeEvent.
*/
function computeChangedLineSpan(
changes: TextDocumentContentChangeEvent[],
): LineSpan | undefined {
if (changes.length === 0) return undefined;
let start = Number.POSITIVE_INFINITY;
let end = Number.NEGATIVE_INFINITY;
for (const c of changes) {
// Full-document sync
if (TextDocumentContentChangeEvent.isFull(c)) {
return { start: 0, end: Number.MAX_SAFE_INTEGER, isFullDocument: true };
}
// Incremental sync
if (TextDocumentContentChangeEvent.isIncremental(c)) {
const { range } = c as TextDocumentContentChangeEvent & { range: Range; };
if (range.start.line < start) start = range.start.line;
if (range.end.line > end) end = range.end.line;
}
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
return { start, end, isFullDocument: false };
}
// compare a Range to a LineSpan, with an optional offset (how many lines to expand the span by)
export function rangeOverlapsLineSpan(
range: Range,
span: { start: number; end: number; },
offset: number = 1,
): boolean {
const safeOffset = Math.max(0, offset);
// Expand the span by `offset` in both directions
const expandedStart = Math.max(0, span.start - safeOffset);
const expandedEnd = span.end + safeOffset;
// Standard closed-interval overlap check:
// [range.start.line, range.end.line] vs [expandedStart, expandedEnd]
return range.start.line <= expandedEnd && range.end.line >= expandedStart;
}
/**
* GLOBAL DOCUMENTS OBJECT (TextDocuments)
*
* This is now the canonical document manager, just like the VS Code sample,
* but parameterized with our LspDocument wrapper.
*
* @example
*
* ```typescript
* const documents = new TextDocuments(TextDocument);
* ```
*/
export const documents = new TextDocuments({
create: (uri, languageId, version, text) =>
new LspDocument({ uri, languageId: languageId || 'fish', version, text }),
update: (doc, changes, version) => {
doc.update(changes, version);
return doc;
},
});
export type Documents = typeof documents;
================================================
FILE: src/documentation.ts
================================================
import { dirname } from 'path';
import { SyntaxNode } from 'web-tree-sitter';
import { Hover, MarkupContent, MarkupKind } from 'vscode-languageserver-protocol/node';
import { execCommandDocs, execCommandType, CompletionArguments, execCompleteSpace, execCompleteCmdArgs, documentCommandDescription, execExpandBraceExpansion } from './utils/exec';
import { getChildNodes, getNodeText } from './utils/tree-sitter';
import { md } from './utils/markdown-builder';
import { Analyzer } from './analyze';
import { getExpandedSourcedFilenameNode } from './parsing/source';
import { isCommand, isOption } from './utils/node-types';
import { LspDocument } from './document';
import { uriToPath } from './utils/translation';
export type markdownFiletypes = 'fish' | 'man';
export function enrichToMarkdown(doc: string): MarkupContent {
return {
kind: MarkupKind.Markdown,
value: [
doc,
].join(),
};
}
export function enrichToCodeBlockMarkdown(doc: string, filetype: markdownFiletypes = 'fish'): MarkupContent {
return {
kind: MarkupKind.Markdown,
value: [
'```' + filetype,
doc.trim(),
'```',
].join('\n'),
};
}
export function enrichWildcard(label: string, documentation: string, examples: [string, string][]): MarkupContent {
const exampleStr: string[] = ['---'];
for (const [cmd, desc] of examples) {
exampleStr.push(`__${cmd}__ - ${desc}`);
}
return {
kind: MarkupKind.Markdown,
value: [
`_${label}_ ${documentation}`,
'---',
exampleStr.join('\n'),
].join('\n'),
};
}
export function enrichCommandArg(doc: string): MarkupContent {
const [_first, ...after] = doc.split('\t');
const first = _first?.trim() || '';
const second = after?.join('\t').trim() || '';
const arg = '__' + first + '__';
const desc = '_' + second + '_';
const enrichedDoc = [
arg,
desc,
].join(' ');
return enrichToMarkdown(enrichedDoc);
}
export function enrichCommandWithFlags(command: string, description: string, flags: string[]): MarkupContent {
const title = description ? `(${md.bold(command)}) ${description}` : md.bold(command);
const flagLines = flags.map(line => line.split('\t'))
.map(line => `${md.bold(line.at(0)!)} ${md.italic(line.slice(1).join(' '))}`);
const result: string[] = [];
result.push(title);
if (flags.length > 0) {
result.push(md.separator());
result.push(flagLines.join(md.newline()));
}
return enrichToMarkdown(result.join(md.newline()));
}
export function handleSourceArgumentHover(analyzer: Analyzer, current: SyntaxNode, document?: LspDocument): Hover | null {
// Get the base directory for resolving relative paths
const baseDir = document ? dirname(uriToPath(document.uri)) : undefined;
const sourceExpanded = getExpandedSourcedFilenameNode(current, baseDir);
if (!sourceExpanded) return null;
const sourceDoc = analyzer.getDocumentFromPath(sourceExpanded);
if (!sourceDoc) {
analyzer.analyzePath(sourceExpanded);
}
return {
contents: enrichToMarkdown([
`${md.boldItalic('SOURCE')} - ${md.italic('https://fishshell.com/docs/current/cmds/source.html')}`,
md.separator(),
`${md.codeBlock('fish', [
'source ' + current.text,
sourceExpanded && sourceExpanded !== current.text ? `# source ${sourceExpanded}` : undefined,
].filter(Boolean).join('\n'))}`,
md.separator(),
md.codeBlock('fish', sourceDoc!.getText()),
].join(md.newline())),
};
}
export async function handleBraceExpansionHover(current: SyntaxNode): Promise {
let text = current.text;
if (isOption(current) || isCommand(current)) {
if (text.includes('=')) {
text = text.slice(text.indexOf('=') + 1).trim();
}
}
const expanded = await execExpandBraceExpansion(text);
if (expanded.trim() === '' || expanded.trim() === '1 |``|') {
return null; // No expansion found, return null
}
const isBraceExpansion = text.includes('{') && text.includes('}');
const headerLines = isBraceExpansion ? [
`${md.boldItalic('BRACE EXPANSION')} - ${md.italic('https://fishshell.com/docs/current/language.html#brace-expansion')}`,
md.separator(),
] : [];
return {
contents: enrichToMarkdown([
...headerLines,
md.codeBlock('fish', current.text),
md.separator(),
md.codeBlock('markdown', expanded),
].join(md.newline())),
};
}
export function handleEndStdinHover(current: SyntaxNode): Hover {
return {
contents: enrichToMarkdown([
`(${md.boldItalic('END STDIN TOKEN')}) ${md.inlineCode(current.text)}`,
md.separator(),
[
// TODO: decide on best wording for this documentation
`The ${md.inlineCode('--')} token is used to denote that the command should ${md.bold('stop reading')} from ${md.inlineCode('/dev/stdin')} for ${md.italic('switches')}, and use the remaining ${md.inlineCode('$argv')} as ${md.italic('positional arguments')}.`,
// '',
// 'Useful when a command accepts switches and arguments that start with a dash (-).',
// '',
// `The ${md.boldItalic(`first`)} ${md.inlineCode('--')} ${md.boldItalic('argument')} that is not an option-argument should be accepted as a ${md.bold('delimiter')} indicating the ${md.bold('end of options')}.`,
// '',
// `Any ${md.bold('following arguments')} should be treated as operands, even if they begin with the ${md.bold('-')} character.`,
// '',
// md.codeBlock('fish', [
// '# example pattern:',
// 'utility_name [options] [--] [operands]'
// ].join(md.newline())),
].join(md.newline()),
md.separator(),
md.codeBlock('fish', [
'### EXAMPLES',
'',
'# 1. `argparse` considers `--help` as input and not an option (variable `_flag_help` is set)',
'argparse h/help -- --help',
'',
'# 2. `markdown_list` is joined without treating the \'- .*\' as options',
'set markdown_list (string join -- \\n \'- first\' \'- second\' \'- third\')',
'',
'# 3. `hasargs` checks if the arguments contains a -q option',
'function hasargs',
' if contains -- -q $argv',
' echo \'$argv contains a -q option\'',
' end',
'end',
].join('\n')),
].join(md.newline())),
};
}
export function enrichToPlainText(doc: string): MarkupContent {
return {
kind: MarkupKind.PlainText,
value: doc.trim(),
};
}
export async function documentationHoverProvider(cmd: string): Promise {
const cmdDocs = await execCommandDocs(cmd);
const cmdType = await execCommandType(cmd);
if (!cmdDocs) {
return null;
} else {
return {
contents: cmdType === 'command'
? enrichToCodeBlockMarkdown(cmdDocs, 'man')
: enrichToCodeBlockMarkdown(cmdDocs, 'fish'),
};
}
}
export async function documentationHoverProviderForBuiltIns(cmd: string): Promise {
const cmdDocs: string = await execCommandDocs(cmd);
if (!cmdDocs) {
return null;
}
const splitDocs = cmdDocs.split('\n');
const startIndex = splitDocs.findIndex((line: string) => line.trim() === 'NAME');
return {
contents: {
kind: MarkupKind.Markdown,
value: [
`__${cmd.toUpperCase()}__ - _https://fishshell.com/docs/current/cmds/${cmd.trim()}.html_`,
'___',
'```man',
splitDocs.slice(startIndex).join('\n'),
'```',
].join('\n'),
},
};
}
function commandStringHelper(cmd: string) {
const cmdArray = cmd.split(' ', 1);
return cmdArray.length > 1
? '___' + cmdArray[0] + '___' + ' ' + cmdArray[1]
: '___' + cmdArray[0] + '___';
}
export function documentationHoverCommandArg(root: SyntaxNode, cmp: CompletionArguments): Hover {
let text = '';
const argsArray = [...cmp.args.keys()];
for (const node of getChildNodes(root)) {
const nodeText = getNodeText(node);
if (nodeText.startsWith('-') && argsArray.includes(nodeText)) {
text += '\n' + '_' + nodeText + '_ ' + cmp.args.get(nodeText);
}
}
const cmd = commandStringHelper(cmp.command.trim());
return {
contents:
enrichToMarkdown(
[
cmd,
'---',
text.trim(),
].join('\n'),
),
};
}
export function forwardSubCommandCollect(rootNode: SyntaxNode): string[] {
const stringToComplete: string[] = [];
for (const curr of rootNode.children) {
if (curr.text.startsWith('-') && curr.text.startsWith('$')) {
break;
} else {
stringToComplete.push(curr.text);
}
}
return stringToComplete;
}
export function forwardArgCommandCollect(rootNode: SyntaxNode): string[] {
const stringToComplete: string[] = [];
for (const curr of rootNode.children) {
if (curr.text.startsWith('-') && curr.text.startsWith('$')) {
stringToComplete.push(curr.text);
} else {
continue;
}
}
return stringToComplete;
}
function getFlagString(arr: string[]): string {
return '__' + arr[0] + '__' + ' ' + arr[1] + '\n';
}
export class HoverFromCompletion {
private currentNode: SyntaxNode;
private commandNode: SyntaxNode;
private commandString: string = '';
private entireCommandString: string = '';
private completions: string[][] = [];
private oldOptions: boolean = false;
private flagsGiven: string[] = [];
constructor(commandNode: SyntaxNode, currentNode: SyntaxNode) {
this.currentNode = currentNode;
this.commandNode = commandNode;
this.commandString = commandNode.child(0)?.text || '';
this.entireCommandString = commandNode.text || '';
this.flagsGiven = this.entireCommandString
.split(' ').slice(1)
.filter(flag => flag.startsWith('-'))
.map(flag => flag.split('=')[0]) as string[] || [];
}
/**
* set this.commandString for possible subcommands
* handles a command such as:
* $ string match -ra '.*' -- "hello all people"
*/
private async checkForSubCommands() {
const spaceCmps = await execCompleteSpace(this.commandString);
if (spaceCmps.length === 0) {
return this.commandString;
}
const cmdArr = this.commandNode.text.split(' ').slice(1);
let i = 0;
while (i < cmdArr.length) {
const argStr = cmdArr[i]!.trim();
if (!argStr.startsWith('-') && spaceCmps.includes(argStr)) {
this.commandString += ' ' + argStr.toString();
} else if (argStr.includes('-')) {
break;
}
i++;
}
return this.commandString;
}
private isSubCommand() {
const currentNodeText = this.currentNode.text;
if (currentNodeText.startsWith('-') || currentNodeText.startsWith("'") || currentNodeText.startsWith('"')) {
return false;
}
const cmdArr = this.commandString.split(' ');
if (cmdArr.length > 1) {
return cmdArr.includes(currentNodeText);
}
return false;
}
/**
* @see man complete: styles --> long options
* enables the ability to differentiate between
* short flags chained together, or a command
* that
* a command option like:
* '-Wall' or --> returns true
* find -name '.git' --> returns true
*
* ls -la --> returns false
* @param {string[]} cmpFlags - [TODO:description]
* @returns {boolean} true if old styles are valid
* false if short flags can be chained
*/
private hasOldStyleFlags() {
for (const cmpArr of this.completions) {
if (cmpArr[0]?.startsWith('--')) {
continue;
} else if (cmpArr[0]?.startsWith('-') && cmpArr[0]?.length > 2) {
return true;
}
}
return false;
}
/**
* handles splitting short options if the command has no
* old style flags.
* @see this.hasOldStyleFlags()
*/
private reparseFlags() {
const shortFlagsHandled = [];
for (const flag of this.flagsGiven) {
if (flag.startsWith('--')) {
shortFlagsHandled.push(flag);
} else if (flag.startsWith('-') && flag.length > 2) {
const splitShortFlags = flag.split('').slice(1).map(str => '-' + str);
shortFlagsHandled.push(...splitShortFlags);
}
}
return shortFlagsHandled;
}
public async buildCompletions() {
this.commandString = await this.checkForSubCommands();
const preBuiltCompletions = await execCompleteCmdArgs(this.commandString);
for (const cmp of preBuiltCompletions) {
this.completions.push(cmp.split('\t'));
}
return this.completions;
}
public findCompletion(flag: string) {
for (const flagArr of this.completions) {
if (flagArr[0] === flag) {
return flagArr;
}
}
return null;
}
private async checkForHoverDoc() {
const cmd = await documentCommandDescription(this.commandString);
const cmdArr = cmd.trim().split(' ');
const cmdStrLen = this.commandString.split(' ').length;
const boldText = '__' + cmdArr.slice(0, cmdStrLen).join(' ') + '__';
const otherText = ' ' + cmdArr.slice(cmdStrLen).join(' ');
return boldText + otherText;
}
public async generateForFlags(): Promise {
let text = '';
this.completions = await this.buildCompletions();
this.oldOptions = this.hasOldStyleFlags();
const cmd = await this.checkForHoverDoc();
if (!this.oldOptions) {
this.flagsGiven = this.reparseFlags();
}
for (const flag of this.flagsGiven) {
const found = this.findCompletion(flag);
if (found) {
text += getFlagString(found);
}
}
return {
contents: enrichToMarkdown([
cmd,
'---',
text.trim(),
].join('\n')),
};
}
public async generateForSubcommand() {
return await documentationHoverProvider(this.commandString);
}
public async generate(): Promise {
this.commandString = await this.checkForSubCommands();
if (this.isSubCommand()) {
const output = await documentationHoverProvider(this.commandString);
if (output) {
return output;
}
} else {
return await this.generateForFlags();
}
return;
}
}
================================================
FILE: src/execute-handler.ts
================================================
import { Connection } from 'vscode-languageserver';
import { exec } from 'child_process';
import { execAsyncFish } from './utils/exec';
import { promisify } from 'util';
import { appendFileSync } from 'fs';
export const execAsync = promisify(exec);
export type ExecResultKind = 'error' | 'info';
export type ExecResultWrapper = {
message: string;
kind: ExecResultKind;
};
export async function execLineInBuffer(line: string): Promise {
const { stderr, stdout } = await execAsync(`fish -c '${line}'; or true`);
if (stderr) {
return { message: buildOutput(line, 'stderr:', stderr), kind: 'error' };
}
if (stdout) {
return { message: buildOutput(line, 'stdout:', stdout), kind: 'info' };
}
return {
message: [
`${fishLspPromptIcon} ${line}`,
'-'.repeat(50),
'EMPTY RESULT',
].join('\n'),
kind: 'info',
};
}
export const fishLspPromptIcon = '><(((°>';
export function buildOutput(line: string, outputMessage: 'error:' | 'stderr:' | 'stdout:', output: string) {
const tokens = line.trim().split(' ');
let promptLine = `${fishLspPromptIcon} `;
let currentLen = promptLine.length;
for (const token of tokens) {
if (1 + token.length + currentLen > 49) {
const newToken = `\\\n ${token} `;
promptLine += newToken;
currentLen = newToken.slice(newToken.indexOf('\n')).length;
} else {
const newToken = token + ' ';
promptLine += newToken;
currentLen += newToken.length + 1;
}
}
return [
promptLine,
'-'.repeat(50),
`${outputMessage} ${output}`,
].join('\n');
}
export function buildExecuteNotificationResponse(
input: string,
output: { stdout: string; stderr: string; },
) {
const outputType = output.stdout ? output.stdout : output.stderr;
const outputMessagePrefix = output.stdout ? 'stdout:' : 'stderr:';
const kind: ExecResultKind = output.stdout ? 'info' : 'error';
return {
message: buildOutput(input, outputMessagePrefix, outputType),
kind,
};
}
export async function execEntireBuffer(bufferName: string): Promise {
const { stdout, stderr } = await execAsync(`fish ${bufferName}`);
const statusOutput = (await execAsync(`fish -c 'fish ${bufferName} 1> /dev/null; echo "\\$status: $status"'`)).stdout;
const headerOutput = [
`${fishLspPromptIcon} executing file:`,
`${' '.repeat(fishLspPromptIcon.length)} ${bufferName}`,
].join('\n');
const longestLineLen = findLongestLine(headerOutput, stdout, stderr, '-'.repeat(50)).length;
let output = '';
if (stdout) output += `${stdout}`;
if (stdout && stderr) output += `\nerror:\n${stderr}`;
else if (!stdout && stderr) output += `error:\n${stderr}`;
let messageType: ExecResultKind = 'info';
if (stderr) messageType = 'error';
if (statusOutput) output += `${'-'.repeat(longestLineLen)}\n${statusOutput}`;
return {
message: [
headerOutput,
'-'.repeat(longestLineLen),
output,
].join('\n'),
kind: messageType,
};
}
export async function sourceFishBuffer(bufferName: string) {
const { stdout, stderr } = await execAsync(`fish -c 'source ${bufferName}'`);
const statusOutput = (await execAsync(`fish -c 'source ${bufferName} 1> /dev/null; echo "\\$status: $status"'`)).stdout;
const message = [
`${fishLspPromptIcon} sourcing file:`,
`${' '.repeat(fishLspPromptIcon.length)} ${bufferName}`,
].join('\n');
const longestLineLen = findLongestLine(message, stdout, stderr, statusOutput, '-'.repeat(50)).length;
const outputArr: string[] = [];
if (statusOutput) outputArr.push(statusOutput);
if (stdout) outputArr.push(stdout);
if (stderr) outputArr.push(stderr);
const output = outputArr.join('-'.repeat(50) + '\n');
return [
message,
'-'.repeat(longestLineLen),
output,
].join('\n');
}
export async function FishThemeDump() {
return (await execAsyncFish('fish_config theme dump; or true')).stdout.split('\n');
}
export async function showCurrentTheme(buffName: string) {
const output = (await execAsyncFish('fish_config theme demo; or true')).stdout.split('\n');
// Append the longest line to the file
for (const line of output) {
appendFileSync(buffName, `${line}\n`, 'utf8');
}
return {
message: `${fishLspPromptIcon} appended theme variables to end of file`,
kind: 'info',
};
}
export type ThemeOptions = {
asVariables: boolean;
};
const defaultThemeOptions: ThemeOptions = {
asVariables: false,
};
export async function executeThemeDump(buffName: string, options: ThemeOptions = defaultThemeOptions): Promise {
const output = (await execAsyncFish('fish_config theme dump; or true')).stdout.split('\n');
// Append the longest line to the file
if (options.asVariables) {
appendFileSync(buffName, '# created by fish-lsp');
}
for (const line of output) {
if (options.asVariables) {
appendFileSync(buffName, `set -gx ${line}\n`, 'utf8');
} else {
appendFileSync(buffName, `${line}\n`, 'utf8');
}
}
return {
message: `${fishLspPromptIcon} appended theme variables to end of file`,
kind: 'info',
};
}
/**
* Function to find the longest line in a string.
* @param input - The input string with lines separated by newline characters.
* @returns The longest line in the input string.
*/
function findLongestLine(...inputs: string[]): string {
const input = inputs.join('\n');
// Split the input string by newline characters into an array of lines
const lines: string[] = input.split('\n');
// Initialize a variable to keep track of the longest line
let longestLine: string = '';
// Iterate over each line
for (const line of lines) {
// If the current line is longer than the longestLine found so far, update longestLine
if (line.length > longestLine.length) {
longestLine = line;
}
}
// Return the longest line found
return longestLine;
}
export function useMessageKind(connection: Connection, result: ExecResultWrapper) {
switch (result.kind) {
case 'info':
connection.window.showInformationMessage(result.message);
return;
case 'error':
connection.window.showErrorMessage(result.message);
return;
default:
return;
}
}
================================================
FILE: src/formatting.ts
================================================
import { exec } from 'child_process';
import { logger } from './logger';
import { LspDocument } from './document';
import { getEnabledIndentRanges } from './parsing/comments';
export async function formatDocumentContent(content: string): Promise {
return new Promise((resolve, _reject) => {
const process = exec('fish_indent', (error, stdout, stderr) => {
if (error) {
// reject(stderr);
logger.log('Formatting Error:', stderr);
} else {
resolve(stdout);
}
});
if (process.stdin) {
process.stdin.write(content);
process.stdin.end();
}
});
}
export async function formatDocumentRangeContent(content: string): Promise {
return new Promise((resolve, _reject) => {
const process = exec('fish_indent --only-indent --only-unindent', (error, stdout, stderr) => {
if (error) {
// reject(stderr);
logger.log('Formatting Error:', stderr);
} else {
resolve(stdout);
}
});
if (process.stdin) {
process.stdin.write(content);
process.stdin.end();
}
});
}
interface OriginalRange {
startMarker: string;
endMarker: string;
originalContent: string;
originalStartComment: string;
originalEndComment: string;
}
export async function formatDocumentWithIndentComments(doc: LspDocument): Promise {
const content = doc.getText();
const formatRanges = getEnabledIndentRanges(doc);
// If full document formatting is allowed, use regular formatting
if (formatRanges.fullDocumentFormatting) {
return formatDocumentContent(content);
}
const lines = content.split('\n');
// Step 1: Replace @fish_indent comments with position markers and collect original content
const originalRanges: OriginalRange[] = [];
let modifiedContent = '';
let currentUnformattedContent = '';
let isInUnformattedRange = false;
let rangeId = 0;
let currentStartMarker = '';
let currentOriginalStartComment = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined || line === null) break;
const trimmedLine = line.trim();
const isIndentComment = /^#\s*@fish_indent(?::\s*(off|on)?)?$/.test(trimmedLine);
const hasInlineIndentComment = /#\s*@fish_indent(?::\s*(off|on)?)?/.test(line);
if (isIndentComment || hasInlineIndentComment) {
// Extract the @fish_indent directive from either standalone or inline comment
const match = isIndentComment
? trimmedLine.match(/^#\s*@fish_indent(?::\s*(off|on)?)?$/)
: line.match(/#\s*@fish_indent(?::\s*(off|on)?)?/);
const directive = match?.[1] || 'on'; // Default to 'on' if no directive specified
if (directive === 'off' && !isInUnformattedRange) {
// Start of unformatted range
isInUnformattedRange = true;
currentUnformattedContent = '';
currentStartMarker = `# @fish_indent_marker_start_${rangeId}`;
if (isIndentComment) {
// Standalone comment - preserve the whole line as the comment
currentOriginalStartComment = line;
modifiedContent += currentStartMarker + '\n';
} else {
// Inline comment - the code before the comment should be formatted
const codeBeforeComment = line.substring(0, line.indexOf('#')).trimEnd();
currentOriginalStartComment = '# @fish_indent: off'; // Store just the directive
// Add the code part to formatted content, then start unformatted range
modifiedContent += codeBeforeComment + '\n';
modifiedContent += currentStartMarker + '\n';
}
} else if (directive === 'on' && isInUnformattedRange) {
// End of unformatted range
isInUnformattedRange = false;
const endMarker = `# @fish_indent_marker_end_${rangeId}`;
modifiedContent += endMarker + '\n';
if (!isIndentComment) {
// Inline comment - add the code part after the marker
const codeBeforeComment = line.substring(0, line.indexOf('#')).trimEnd();
if (codeBeforeComment.trim()) {
modifiedContent += codeBeforeComment + '\n';
}
}
originalRanges.push({
startMarker: currentStartMarker,
endMarker,
originalContent: currentUnformattedContent,
originalStartComment: currentOriginalStartComment,
originalEndComment: isIndentComment ? line : '# @fish_indent: on',
});
rangeId++;
} else {
// Not a directive that changes state, treat as regular line
if (isInUnformattedRange) {
currentUnformattedContent += (currentUnformattedContent ? '\n' : '') + line;
} else {
modifiedContent += line + '\n';
}
}
} else {
if (isInUnformattedRange) {
// Collect original content for later restoration
currentUnformattedContent += (currentUnformattedContent ? '\n' : '') + line;
}
// Always add the line to modifiedContent (it will be formatted if not in unformatted range)
modifiedContent += line + '\n';
}
}
// Handle case where document ends with an unformatted range (missing @fish_indent: on)
if (isInUnformattedRange) {
const endMarker = `# @fish_indent_marker_end_${rangeId}`;
modifiedContent += endMarker + '\n';
originalRanges.push({
startMarker: currentStartMarker,
endMarker,
originalContent: currentUnformattedContent,
originalStartComment: currentOriginalStartComment,
originalEndComment: '', // No end comment if document ends with unformatted range
});
}
// Step 2: Format the modified content with fish_indent
const formattedContent = await formatDocumentContent(modifiedContent.trim());
// Step 3: Restore original unformatted content between markers, including original comments
let result = formattedContent;
for (const range of originalRanges) {
const startIndex = result.indexOf(range.startMarker);
const endIndex = result.indexOf(range.endMarker);
if (startIndex !== -1 && endIndex !== -1) {
// Replace everything between (and including) the markers with original content
const beforeMarker = result.substring(0, startIndex);
const afterMarker = result.substring(endIndex + range.endMarker.length);
// Reconstruct with original comments and content
// Extract the indentation context from the formatted content around the markers
const beforeLines = beforeMarker.split('\n');
const lastFormattedLine = beforeLines[beforeLines.length - 2] || ''; // Line before the marker
const startIndentation = lastFormattedLine.match(/^(\s*)/)?.[1] || '';
// For the end comment, we need to determine what indentation level it should have
// The end comment should maintain the same indentation level as the context it's in
// In most cases, this should match the start comment's indentation since they're in the same block
// However, we need to check if we're inside a function or other block structure
// by looking at the original comment's indentation level
const originalStartIndent = range.originalStartComment.match(/^(\s*)/)?.[1] || '';
// Use the original start comment's indentation level for the end comment
// This preserves the user's intended structure
const endIndentation = originalStartIndent;
// Preserve the comment text but adjust indentation to match context
// Extract original comment content without leading whitespace, but preserve trailing whitespace
const startCommentText = range.originalStartComment.replace(/^\s*/, '');
const endCommentText = range.originalEndComment.replace(/^\s*/, '');
let replacement = startIndentation + startCommentText + '\n';
if (range.originalContent.trim()) {
replacement += range.originalContent + '\n';
}
if (endCommentText) {
replacement += endIndentation + endCommentText;
}
result = beforeMarker + replacement + afterMarker;
}
}
return result;
}
export async function formatDocumentRangeWithIndentComments(
doc: LspDocument,
startLine: number,
endLine: number,
): Promise {
// For range formatting, we need to use the same marker-based approach
// but only apply it to the specific range requested
// If the range doesn't intersect with any @fish_indent comments,
// we can use a simpler approach
const formatRanges = getEnabledIndentRanges(doc);
if (formatRanges.fullDocumentFormatting) {
// No @fish_indent comments, just format the range normally
const content = doc.getText();
const lines = content.split('\n');
const rangeLines = lines.slice(startLine, endLine + 1);
const rangeContent = rangeLines.join('\n');
const formattedRangeContent = await formatDocumentContent(rangeContent);
const formattedLines = [...lines];
const newLines = formattedRangeContent.split('\n');
// Replace the range, handling potential line count changes
formattedLines.splice(startLine, endLine - startLine + 1, ...newLines);
return formattedLines.join('\n');
}
// If there are @fish_indent comments, we need to format the entire document
// using our marker approach, then extract only the requested range
const fullFormattedContent = await formatDocumentWithIndentComments(doc);
// const fullFormattedLines = fullFormattedContent.split('\n');
// Find the corresponding lines in the formatted content
// This is tricky because line numbers may have changed due to fish_indent
// For now, return the full formatted content (which preserves all functionality)
// A more sophisticated approach would map the original range to the formatted range
return fullFormattedContent;
}
================================================
FILE: src/hover.ts
================================================
import * as LSP from 'vscode-languageserver';
import { Hover, MarkupKind } from 'vscode-languageserver-protocol/node';
import * as Parser from 'web-tree-sitter';
import { Analyzer } from './analyze';
import { LspDocument } from './document';
import { documentationHoverProvider, enrichCommandWithFlags, enrichToMarkdown } from './documentation';
import { DocumentationCache } from './utils/documentation-cache';
import { execCommandDocs, execCompletions, execSubCommandCompletions } from './utils/exec';
import { findParent, findParentCommand, isCommand, isFunctionDefinition, isOption, isProgram, isVariableDefinitionName, isVariableExpansion, isVariableExpansionWithName } from './utils/node-types';
import { findFirstParent, nodeLogFormatter } from './utils/tree-sitter';
import { symbolKindsFromNode, uriToPath } from './utils/translation';
import { logger } from './logger';
import { PrebuiltDocumentationMap } from './utils/snippets';
import { md } from './utils/markdown-builder';
import { AutoloadedPathVariables } from './utils/process-env';
export async function handleHover(
analyzer: Analyzer,
document: LspDocument,
position: LSP.Position,
current: Parser.SyntaxNode,
cache: DocumentationCache,
): Promise {
if (isOption(current)) {
return await getHoverForFlag(current);
}
const local = analyzer.getDefinition(document, position);
logger.log({
handleHover: handleHover.name,
symbol: local?.name,
position,
current: nodeLogFormatter(current),
});
if (local) {
return {
contents: local.toMarkupContent(),
range: local.selectionRange,
};
}
const { kindType, kindString } = symbolKindsFromNode(current);
const symbolType = ['function', 'class', 'variable'].includes(kindString) ? kindType : undefined;
if (cache.find(current.text) !== undefined) {
await cache.resolve(current.text, document.uri, symbolType);
const item = symbolType ? cache.find(current.text, symbolType) : cache.getItem(current.text);
if (item && item?.docs) {
return {
contents: {
kind: MarkupKind.Markdown,
value: item.docs.toString(),
},
};
}
}
const commandString = await collectCommandString(current);
const result = await documentationHoverProvider(commandString);
logger.log({ handleHover: 'handleHover()', commandString, result });
return result;
}
export async function getHoverForFlag(current: Parser.SyntaxNode): Promise {
const commandNode = findFirstParent(current, n => isCommand(n) || isFunctionDefinition(n));
if (!commandNode) {
return null;
}
let commandStr = [commandNode.child(0)?.text || ''];
const flags: string[] = [];
let hasFlags = false;
for (const child of commandNode?.children || []) {
if (!hasFlags && !child.text.startsWith('-')) {
commandStr = await appendToCommand(commandStr, child.text);
} else if (child.text.startsWith('-')) {
flags.push(child.text);
hasFlags = true;
}
}
const flagCompletions = await execCompletions(...commandStr, '-');
const shouldSplitShortFlags = hasOldUnixStyleFlags(flagCompletions);
const fixedFlags = spiltShortFlags(flags, !shouldSplitShortFlags);
const found = flagCompletions
.map(line => line.split('\t'))
.filter(line => fixedFlags.includes(line[0] as string))
.map(line => line.join('\t'));
/** find exact match for command */
const prebuiltDocs = PrebuiltDocumentationMap.findMatchingNames(
commandStr.join('-'),
'command',
).find(doc => doc.name === commandStr.join('-'));
const description = !prebuiltDocs ? '' : prebuiltDocs?.description || '';
return {
contents: enrichCommandWithFlags(commandStr.join('-'), description, found),
};
}
function hasOldUnixStyleFlags(allFlags: string[]) {
for (const line of allFlags.map(line => line.split('\t'))) {
const flag = line[0] as string;
if (flag.startsWith('-') && !flag.startsWith('--')) {
if (flag.length > 2) {
return true;
}
}
}
return false;
}
function spiltShortFlags(flags: string[], shouldSplit: boolean): string[] {
const newFlags: string[] = [];
for (let flag of flags) {
flag = flag.split('=')[0] as string;
if (flag.startsWith('-') && !flag.startsWith('--')) {
if (flag.length > 2 && shouldSplit) {
newFlags.push(...flag.split('').map(f => '-' + f));
continue;
}
}
newFlags.push(flag);
}
return newFlags;
}
async function appendToCommand(commands: string[], subCommand: string): Promise {
const completions = await execSubCommandCompletions(...commands, ' '); // HERE
if (completions.includes(subCommand)) {
commands.push(subCommand);
return commands;
} else {
return commands;
}
}
export async function collectCommandString(current: Parser.SyntaxNode): Promise {
const commandNode = findFirstParent(current, n => isCommand(n));
if (!commandNode) {
return '';
}
const commandNodeText = commandNode.child(0)?.text;
const subCommandName = commandNode.child(1)?.text;
if (subCommandName?.startsWith('-')) {
return commandNodeText || '';
}
const commandText = [commandNodeText, subCommandName].join('-');
const docs = await execCommandDocs(commandText);
if (docs) {
return commandText;
}
return commandNodeText || '';
}
const allVariables = PrebuiltDocumentationMap.getByType('variable');
export function isPrebuiltVariableExpansion(node: Parser.SyntaxNode): boolean {
if (isVariableExpansion(node)) {
const variableName = node.text.slice(1);
return allVariables.some(variable => variable.name === variableName);
}
return false;
}
export function getPrebuiltVariableExpansionDocs(node: Parser.SyntaxNode): LSP.MarkupContent | null {
if (isVariableExpansion(node)) {
const variableName = node.text.slice(1);
const variable = allVariables.find(variable => variable.name === variableName);
if (variable) {
return enrichToMarkdown([
`(${md.italic('variable')}) - ${md.inlineCode('$' + variableName)}`,
md.separator(),
variable.description,
].join('\n'));
}
}
return null;
}
export const variablesWithoutLocalDocumentation = [
'$status',
'$pipestatus',
];
export function getVariableExpansionDocs(analyzer: Analyzer, doc: LspDocument, position: LSP.Position) {
function isVariablesWithoutLocalDocumentation(current: Parser.SyntaxNode) {
return variablesWithoutLocalDocumentation.includes('$' + current.text);
}
/**
* Use this to append prebuilt documentation to variables with local documentation
*/
function getPrebuiltVariableHoverContent(current: Parser.SyntaxNode): string | null {
const docObject = allVariables.find(variable => variable.name === current.text);
if (!docObject) return null;
return [
`(${md.italic('variable')}) ${md.bold(current.text)}`,
md.separator(),
docObject.description,
].join('\n');
}
return function isPrebuiltExpansionDocsForVariable(current: Parser.SyntaxNode) {
if (isVariableDefinitionName(current)) {
const variableName = current.text;
const parent = findParentCommand(current);
if (AutoloadedPathVariables.has(variableName)) {
return {
contents: enrichToMarkdown(
[
AutoloadedPathVariables.getHoverDocumentation(variableName),
md.separator(),
md.codeBlock('fish', parent?.text || ''),
].join('\n'),
),
};
}
if (isVariablesWithoutLocalDocumentation(current)) {
return {
contents: enrichToMarkdown([
getPrebuiltVariableHoverContent(current),
md.separator(),
md.codeBlock('fish', parent?.text || ''),
].join('\n')),
};
}
if (allVariables.find(variable => variable.name === current.text)) {
return {
contents: enrichToMarkdown([
getPrebuiltVariableHoverContent(current),
md.separator(),
md.codeBlock('fish', parent?.text || ''),
].join('\n')),
};
}
return null;
}
if (current.type === 'variable_name' && current.parent && isVariableExpansion(current.parent)) {
const variableName = current.text;
if (AutoloadedPathVariables.has(variableName)) {
return {
contents: enrichToMarkdown(
AutoloadedPathVariables.getHoverDocumentation(variableName),
),
};
}
// argv
const node = current.parent;
if (isVariableExpansionWithName(node, 'argv')) {
const parentNode = findParent(node, (n) => isProgram(n) || isFunctionDefinition(n)) as Parser.SyntaxNode;
const variableName = node.text.slice(1);
const variableDocObj = allVariables.find(variable => variable.name === variableName);
if (isFunctionDefinition(parentNode)) {
const functionName = parentNode.firstNamedChild!;
return {
contents: enrichToMarkdown([
`(${md.italic('variable')}) ${md.bold('$argv')}`,
`argument of function ${md.bold(functionName.text)}`,
md.separator(),
variableDocObj?.description,
md.separator(),
md.codeBlock('fish', parentNode.text),
].join('\n')),
};
} else if (isProgram(parentNode)) {
return {
contents: enrichToMarkdown([
`(${md.italic('variable')}) ${md.bold('$argv')}`,
`arguments of script ${md.bold(uriToPath(doc.uri))}`,
md.separator(),
variableDocObj?.description,
md.separator(),
md.codeBlock('fish', parentNode.text),
].join('\n')),
};
}
} else if (variablesWithoutLocalDocumentation.includes(node.text)) {
// status && pipestatus
return { contents: getPrebuiltVariableExpansionDocs(node)! };
} else if (!analyzer.getDefinition(doc, position) && isPrebuiltVariableExpansion(node)) {
// variables which aren't defined in lsp's scope, but are documented
const contents = getPrebuiltVariableExpansionDocs(node);
if (contents) return { contents };
}
// consider enhancing variables with local documentation's, with their prebuilt documentation
}
return null;
};
}
================================================
FILE: src/inlay-hints.ts
================================================
import { InlayHint, InlayHintKind } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { PrebuiltDocumentationMap } from './utils/snippets';
import { isCommand, isCommandName, isReturn, isExit } from './utils/node-types';
import { findChildNodes } from './utils/tree-sitter';
import { Analyzer } from './analyze';
import { LspDocument } from './document';
import { getReferences } from './references';
import { logger } from './logger';
export function getStatusInlayHints(root: SyntaxNode): InlayHint[] {
const hints: InlayHint[] = [];
const returnStatements = findChildNodes(root, isReturn);
const exitStatements = findChildNodes(root, isExit);
for (const returnStmt of returnStatements) {
const status = getReturnStatusValue(returnStmt);
if (status) {
hints.push({
position: {
line: returnStmt.endPosition.row,
character: returnStmt.endPosition.column,
},
kind: InlayHintKind.Parameter,
label: ` → ${status.inlineValue}`,
paddingLeft: true,
tooltip: {
kind: 'markdown',
value: `Status code ${status.tooltip.code}: ${status.tooltip.description}`,
},
});
}
}
for (const exitStmt of exitStatements) {
const status = getExitStatusValue(exitStmt);
if (status) {
hints.push({
position: {
line: exitStmt.endPosition.row,
character: exitStmt.endPosition.column,
},
kind: InlayHintKind.Parameter,
label: ` → ${status.inlineValue}`,
paddingLeft: true,
tooltip: {
kind: 'markdown',
value: `Exit code ${status.tooltip.code}: ${status.tooltip.description}`,
},
});
}
}
return hints;
}
export function findReturnNodes(root: SyntaxNode): SyntaxNode[] {
const nodes: SyntaxNode[] = [];
const queue = [root];
while (queue.length > 0) {
const node = queue.shift()!;
if (isReturn(node)) {
nodes.push(node);
}
queue.push(...node.children);
}
return nodes;
}
function getStatusDescription(status: string): string {
const statusMap: Record = {
0: 'Success',
1: 'General error',
2: 'Misuse of shell builtins',
126: 'Command invoked cannot execute',
127: 'Command not found',
128: 'Invalid exit argument',
130: 'Script terminated by Control-C',
};
return statusMap[status] || `Exit code ${status}`;
}
export function getReturnStatusValue(returnNode: SyntaxNode): {
inlineValue: string;
tooltip: {
code: string;
description: string;
};
} | undefined {
const statusArg = returnNode.children.find(child =>
!isCommand(child) && !isCommandName(child) && child.type === 'integer');
if (!statusArg?.text) return undefined;
const statusInfo = PrebuiltDocumentationMap.getByName(statusArg.text).pop();
const statusInfoShort = getStatusDescription(statusArg.text);
return statusInfoShort ? {
inlineValue: statusInfoShort,
tooltip: {
code: statusInfo?.name || statusArg.text,
description: statusInfo?.description || statusInfoShort,
},
} : undefined;
}
export function getExitStatusValue(exitNode: SyntaxNode): {
inlineValue: string;
tooltip: {
code: string;
description: string;
};
} | undefined {
const statusArg = exitNode.children.find(child =>
!isCommand(child) && !isCommandName(child) && child.type === 'integer');
if (!statusArg?.text) return undefined;
const statusInfo = PrebuiltDocumentationMap.getByName(statusArg.text).pop();
const statusInfoShort = getStatusDescription(statusArg.text);
return statusInfoShort ? {
inlineValue: statusInfoShort,
tooltip: {
code: statusInfo?.name || statusArg.text,
description: statusInfo?.description || statusInfoShort,
},
} : undefined;
}
// Add a cache for the entire inlay hints result
type InlayHintsCache = {
hints: InlayHint[];
timestamp: number;
version: number; // Track document version
};
const inlayHintsCache = new Map();
const INLAY_HINTS_TTL = 1500; // 1.5 seconds TTL for full hints refresh
function getCachedInlayHints(
uri: string,
documentVersion: number,
): InlayHint[] | undefined {
const entry = inlayHintsCache.get(uri);
if (!entry) return undefined;
// Return nothing if document version changed or cache is too old
if (entry.version !== documentVersion ||
Date.now() - entry.timestamp > INLAY_HINTS_TTL) {
inlayHintsCache.delete(uri);
return undefined;
}
return entry.hints;
}
function setCachedInlayHints(
uri: string,
hints: InlayHint[],
documentVersion: number,
) {
inlayHintsCache.set(uri, {
hints,
timestamp: Date.now(),
version: documentVersion,
});
}
export function getGlobalReferencesInlayHints(
analyzer: Analyzer,
document: LspDocument,
): InlayHint[] {
// Try to get cached hints first
const cachedHints = getCachedInlayHints(document.uri, document.version);
if (cachedHints) {
logger?.log('Using cached inlay hints');
return cachedHints;
}
logger?.log('Computing new inlay hints');
const hints: InlayHint[] = analyzer.getFlatDocumentSymbols(document.uri)
.filter(symbol => symbol.scope.scopeTag === 'global' || symbol.scope.scopeTag === 'universal')
.map(symbol => {
const referenceCount = getReferences(document, symbol.selectionRange.start).length;
return {
position: document.getLineEnd(symbol.selectionRange.start.line),
kind: InlayHintKind.Type,
label: `${referenceCount} reference${referenceCount === 1 ? '' : 's'}`,
paddingLeft: true,
tooltip: {
kind: 'markdown',
value: `${symbol.name} is referenced ${referenceCount} time${referenceCount === 1 ? '' : 's'} across the workspace`,
},
};
});
// Cache the new hints
setCachedInlayHints(document.uri, hints, document.version);
return hints;
}
// Function to invalidate cache when document changes
export function invalidateInlayHintsCache(uri: string) {
inlayHintsCache.delete(uri);
}
export function getAllInlayHints(analyzer: Analyzer, document: LspDocument): InlayHint[] {
const results: InlayHint[] = [];
const root = analyzer.getRootNode(document.uri);
if (root) {
results.push(...getStatusInlayHints(root));
}
return results;
}
================================================
FILE: src/linked-editing.ts
================================================
import { LinkedEditingRanges, Position, Range } from 'vscode-languageserver';
import { LspDocument } from './document';
import { analyzer } from './analyze';
import { isFunctionDefinition, isStatement, isEnd } from './utils/node-types';
import { SyntaxNode } from 'web-tree-sitter';
/**
* Get linked editing ranges for a position in a document.
* Returns ranges that should be edited together, such as:
* - function keyword and end keyword in function definitions
* - statement keywords (if, for, while, switch, begin) and their corresponding end keywords
*
* @param doc - The document to search
* @param position - The position to check for linked editing ranges
* @returns LinkedEditingRanges or null if no linked ranges found
*/
export function getLinkedEditingRanges(
doc: LspDocument,
position: Position,
): LinkedEditingRanges | null {
const current = analyzer.nodeAtPoint(doc.uri, position.line, position.character);
if (!current) return null;
// Find the parent statement or function definition
let targetNode: SyntaxNode | null = null;
let node: SyntaxNode | null = current;
while (node) {
if (isFunctionDefinition(node) || isStatement(node)) {
targetNode = node;
break;
}
node = node.parent;
}
if (!targetNode) return null;
// Get the first and last children to find the opening and closing keywords
const firstChild = targetNode.firstChild;
const lastChild = targetNode.lastChild;
if (!firstChild || !lastChild) return null;
// Check that we have a proper block with 'end' keyword
if (!isEnd(lastChild)) return null;
const ranges: Range[] = [];
// Add the opening keyword range (function, if, for, while, switch, begin)
ranges.push(Range.create(
doc.positionAt(firstChild.startIndex),
doc.positionAt(firstChild.endIndex),
));
// Add the end keyword range
ranges.push(Range.create(
doc.positionAt(lastChild.startIndex),
doc.positionAt(lastChild.endIndex),
));
return {
ranges,
};
}
================================================
FILE: src/logger.ts
================================================
import * as console from 'node:console';
import fs from 'fs';
import { config } from './config';
export interface IConsole {
error(...args: any[]): void;
warn(...args: any[]): void;
info(...args: any[]): void;
debug(...args: any[]): void;
log(...args: any[]): void;
}
export const LOG_LEVELS = ['error', 'warning', 'info', 'debug', 'log', ''] as const;
export const DEFAULT_LOG_LEVEL: LogLevel = 'log';
export type LogLevel = typeof LOG_LEVELS[number];
export const LogLevel: Record = {
error: 1,
warning: 2,
info: 3,
debug: 4,
log: 5,
'': 6,
};
function getLogLevel(level: string): LogLevel {
if (LOG_LEVELS.includes(level as LogLevel)) {
return level as LogLevel;
}
return DEFAULT_LOG_LEVEL;
}
export class Logger {
/** The default console object */
protected _console: IConsole = console;
/** never print to console */
private _silence: boolean = false;
/** clear the log file once a log file has been set */
private _clear: boolean = true;
/** logs that were requested before a log file was set */
private _logQueue: string[] = [];
/** path to the log file */
public logFilePath: string = '';
/** set to true if the logger has been started */
private started = false;
/** set to true if the logger is connected to a server/client connection */
private isConnectedToConnection = false;
/** requires the server/client connection object to console.log() */
private requiresConnectionConsole = true;
/** set to true if the logger is connected to a server/client connection */
private _logLevel: LogLevel = '';
constructor(logFilePath: string = '') {
this.logFilePath = logFilePath;
// Bind methods to ensure proper this context
this.log = this.log.bind(this);
this.debug = this.debug.bind(this);
this.info = this.info.bind(this);
this.warning = this.warning.bind(this);
this.error = this.error.bind(this);
this._log = this._log.bind(this);
this.convertArgsToString = this.convertArgsToString.bind(this);
this._logWithSeverity = this._logWithSeverity.bind(this);
this.logAsJson = this.logAsJson.bind(this);
this.logFallbackToStdout = this.logFallbackToStdout.bind(this);
}
/**
* Set the log file path
*/
setLogFilePath(logFilePath: string): this {
this.logFilePath = logFilePath;
return this;
}
/**
* Set the this._console to a connection.console and update the isConnectedToConnection property
*/
setConnectionConsole(_console: IConsole | undefined): this {
if (_console) {
this._console = _console;
this.isConnectedToConnection = true;
}
return this;
}
/**
* Just set the console object, without changing the isConnectedToConnection property
* This is useful for testing, with the requiresConnectionConsole property set to false
*/
setConsole(_console: IConsole | undefined): this {
if (_console) {
this._console = _console;
}
return this;
}
setClear(clear: boolean = true): this {
this._clear = clear;
return this;
}
/**
* Set the silence flag, so that console.log() will not be shown
* This is used to make logging only appear in the log file.
*/
setSilent(silence: boolean = true): this {
this._silence = silence;
return this;
}
/**
* Set logLevel to a specific level
*/
setLogLevel(level: string): this {
const logLevel = getLogLevel(level);
if (LOG_LEVELS.includes(logLevel)) {
this._logLevel = logLevel;
}
return this;
}
/**
* Allow using the default console object, instead of requiring the server to be connected to a server/client connection
*/
allowDefaultConsole(): this {
this.requiresConnectionConsole = false;
return this;
}
isConnectionConsole(): boolean {
return this.isConnectedToConnection;
}
isStarted(): boolean {
return this.started;
}
isSilent(): boolean {
return this._silence;
}
isClearing(): boolean {
return this._clear;
}
isConnected(): boolean {
return this.isConnectedToConnection && this.requiresConnectionConsole;
}
hasLogLevel(): boolean {
return this._logLevel !== '';
}
hasConsole(): boolean {
if (this.isConnectionConsole()) {
return this.isConnected();
}
return this._console !== undefined;
}
start(): this {
this.started = true;
this.clearLogFile();
this._logQueue.forEach((message) => {
this._log(message);
});
return this;
}
hasLogFile(): boolean {
return this.logFilePath !== '';
}
/**
* Only clears the log file if this option has been enabled.
*/
private clearLogFile(): void {
if (this.isClearing() && this.hasLogFile()) {
try {
fs.writeFileSync(this.logFilePath, '');
} catch (error) {
this._console.error(`Error clearing log file: ${error}`);
}
}
}
/**
* Converts arguments to a formatted string for logging
* Handles various types of arguments with special handling for different types
*
* @param args - Arguments to convert to string
* @returns Formatted string representation
*/
convertArgsToString(...args: any[]): string {
if (!args || args.length === 0) {
return '';
}
// Format each argument appropriately
const formattedArgs = args.map(arg => this.formatArgument(arg));
// Join with newlines if multiple arguments
return formattedArgs.length === 1
? formattedArgs.at(0) || ''
: formattedArgs.join('\n');
}
/**
* Formats a single argument into a string representation
*
* @param arg - The argument to format
* @returns Formatted string representation
*/
private formatArgument(arg: any): string {
// Handle null and undefined
if (arg === null) return 'null';
if (arg === undefined) return 'undefined';
// Handle Error objects
if (arg instanceof Error) {
return arg.stack || arg.message || String(arg);
}
// Handle primitive types
if (typeof arg === 'string') return arg;
if (typeof arg !== 'object') return String(arg);
// Handle Date objects
if (arg instanceof Date) {
return arg.toISOString();
}
// Handle Arrays specially for better readability
if (Array.isArray(arg)) {
if (arg.length === 0) return '[]';
// For small arrays of primitives, format on one line
if (arg.length < 5 && arg.every(item =>
item === null ||
item === undefined ||
typeof item !== 'object')) {
return JSON.stringify(arg);
}
}
// Handle objects and arrays with circular reference protection
try {
const seen = new WeakSet();
return JSON.stringify(arg, (key, value) => {
// Skip functions
if (typeof value === 'function') {
return '[Function]';
}
// Handle RegExp
if (value instanceof RegExp) {
return value.toString();
}
// Skip circular references
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
}
return value;
}, 2);
} catch (err) {
// Fallback in case of JSON.stringify failure
try {
const className = arg.constructor?.name || 'Object';
const properties = Object.keys(arg).length > 0
? `with ${Object.keys(arg).length} properties`
: 'empty';
return `[${className}: ${properties}]`;
} catch {
return '[Object: stringify failed]';
}
}
}
private _log(...args: any[]): void {
if (!args) return;
if (!this.isSilent() && this.hasConsole()) this._console.log(...args);
const formattedMessage = this.convertArgsToString(...args);
if (this.hasLogFile()) {
fs.appendFileSync(this.logFilePath, formattedMessage + '\n', 'utf-8');
} else {
this._logQueue.push(formattedMessage);
}
}
public logAsJson(...args: any[]) {
if (!args || args.some(arg => !arg)) return;
const formattedMessage = this.convertArgsToString(args);
this._log({
date: new Date().toLocaleString(),
message: formattedMessage,
});
}
private _logWithSeverity(severity: LogLevel, ...args: string[]): void {
if (this.hasLogLevel() && LogLevel[this._logLevel] < LogLevel[severity]) {
return;
}
const formattedMessage = [severity.toUpperCase() + ':', this.convertArgsToString(...args)].join(' ');
this._log(formattedMessage);
}
logPropertiesForEachObject>(objs: T[], ...keys: (keyof T)[]): void {
objs.forEach((obj, i) => {
// const selectedKeys = keys.filter(key => obj.hasOwnProperty(key));
const selectedKeys = keys.filter(key => Object.prototype.hasOwnProperty.bind(obj, key));
const selectedObj = selectedKeys.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {} as Partial);
const formattedMessage = `${i}: ${JSON.stringify(selectedObj, null, 2)}`;
this._log(formattedMessage);
});
}
public logTime(...args: any[]): void {
const formattedMessage = this.convertArgsToString(...args);
const time = new Date().toLocaleTimeString();
this._log(`[${time}] ${formattedMessage}`);
}
public log(...args: any[]): void {
if (!args) return;
const formattedMessage = this.convertArgsToString(...args);
if (!this.hasLogLevel()) {
this._log(formattedMessage);
return;
}
this._logWithSeverity('log', formattedMessage);
}
public debug(...args: any[]): void {
this._logWithSeverity('debug', ...args);
}
public info(...args: any[]): void {
this._logWithSeverity('info', ...args);
}
public warning(...args: any[]): void {
this._logWithSeverity('warning', ...args);
}
public error(...args: any[]): void {
this._logWithSeverity('error', ...args);
}
/**
* Util for logging to stdout, with optional trailing newline.
* Will not include any logs that are passed in to the logger.
* @param message - the message to log
* @param newline - whether to add a trailing newline
*/
public logToStdout(message: string, newline = true): void {
const newlineChar = newline ? '\n' : '';
const output: string = `${message}${newlineChar}`;
process.stdout.write(output);
}
/**
* Util for joining multiple strings and logging to stdout with trailing `\n`
* Will not include any logs that are passed in to the logger.
*/
public logToStdoutJoined(...message: string[]): void {
const output: string = `${message.join('')}\n`;
process.stdout.write(output);
}
public logToStderr(message: string, newline = true): void {
const output: string = `${message}${!!newline && '\n'}`;
process.stderr.write(output);
}
/**
* A helper function to wrap default logging behavior for the logger, if it is started.
* - If logger is started, log to logger `logger.log()`
* - If logger is not started, log to stdout `logToStdout()`
*
* @param args - any number of arguments to log
* @returns void
*/
public logFallbackToStdout(...args: any[]): void {
if (this.isStarted()) {
this.log(...args);
} else {
this.logToStdout(JSON.stringify(args, null, 2), true);
}
}
}
export function now(): string {
const currentTime = new Date();
const hours = currentTime.getHours();
const hour12 = hours % 12 || 12;
const ampm = hours >= 12 ? 'PM' : 'AM';
return [
hour12.toString().padStart(2, '0'),
currentTime.getMinutes().toString().padStart(2, '0'),
currentTime.getSeconds().toString().padStart(2, '0'),
Math.floor(currentTime.getMilliseconds() / 10).toString().padStart(2, '0'),
].join(':') + ` ${ampm}`;
}
export const logger: Logger = new Logger();
export function createServerLogger(logFilePath: string, connectionConsole?: IConsole): Logger {
return logger
.setLogFilePath(logFilePath)
.setConnectionConsole(connectionConsole)
.setSilent()
.setLogLevel(config.fish_lsp_log_level as LogLevel)
.start();
}
================================================
FILE: src/main.ts
================================================
#!/usr/bin/env node
// Enable source map support for better stack traces
import 'source-map-support/register';
// Universal entry point for fish-lsp that handles CLI, Node.js module, and browser usage
// This single file replaces the need for separate entry points and wrappers
// Import polyfills for compatibility
import './utils/polyfills';
// Initialize virtual filesystem first (must be before any fs operations)
import './virtual-fs';
import './utils/commander-cli-subcommands';
import { execCLI } from './cli';
// Environment detection
function isBrowserEnvironment(): boolean {
return typeof window !== 'undefined' || typeof self !== 'undefined';
}
function isRunningAsCLI(): boolean {
return !isBrowserEnvironment() && require.main === module;
}
// CLI functionality - only load when needed
async function runCLI() {
execCLI();
}
// Import web module to ensure it's bundled and can auto-initialize
import './web';
// Export both Node.js and web versions
export { default as FishServer } from './server';
export { FishLspWeb } from './web';
export { setExternalConnection, createConnectionType } from './utils/startup';
export type { ConnectionType, ConnectionOptions } from './utils/startup';
// Default export for CommonJS compatibility
import FishServer from './server';
export default FishServer;
// Auto-initialization based on environment
if (isBrowserEnvironment()) {
// Browser environments are auto-initialized by web.ts itself
// No need to do anything here
} else if (isRunningAsCLI() || process.env.NODE_ENV === 'test') {
// Auto-run CLI if this file is executed directly
runCLI().catch(async (error) => {
const { logger } = await import('./logger');
logger.logToStderr(`Failed to start fish-lsp CLI: ${error}`);
process.exit(1);
});
}
================================================
FILE: src/parser.ts
================================================
import Parser from 'web-tree-sitter';
import treeSitterWasmPath from 'web-tree-sitter/tree-sitter.wasm';
import fishLanguageWasm from '@esdmr/tree-sitter-fish/tree-sitter-fish.wasm';
import { logger } from './logger';
const _global: any = global;
export async function initializeParser(): Promise {
if (_global.fetch) {
delete _global.fetch;
}
if (!_global.Module) {
_global.Module = {
onRuntimeInitialized: () => { },
instantiateWasm: undefined,
locateFile: undefined,
wasmBinary: undefined,
};
}
// treeSitterWasmPath is already a Uint8Array from the esbuild plugin
// which reads web-tree-sitter/tree-sitter.wasm and embeds it
const tsWasmBuffer = bufferToUint8Array(treeSitterWasmPath);
// Initialize Parser with embedded WASM binary
await Parser.init({
wasmBinary: tsWasmBuffer,
});
const parser = new Parser();
const fishWasmBuffer = bufferToUint8Array(fishLanguageWasm); // \0asm
try {
const lang = await Parser.Language.load(fishWasmBuffer);
parser.setLanguage(lang);
} catch (error) {
logger.logToStderr('Failed to load fish language grammar for tree-sitter parser.');
console.error('Error loading fish language grammar:', error);
throw error;
}
return parser;
}
function bufferToUint8Array(buffer: ArrayBuffer | Buffer | string): Uint8Array {
if (typeof buffer === 'string' && buffer.startsWith('data:application/wasm;base64,')) {
const base64Data = buffer.replace('data:application/wasm;base64,', '');
return Buffer.from(base64Data, 'base64');
} else if (typeof buffer === 'string') {
return Buffer.from(buffer, 'base64');
} else {
return buffer as Uint8Array;
}
}
================================================
FILE: src/parsing/alias.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { FishSymbol } from './symbol';
import { DefinitionScope, getScope } from '../utils/definition-scope';
import { LspDocument } from '../document';
import { getRange } from '../utils/tree-sitter';
import { findParentWithFallback, isCommandWithName, isConcatenation, isFunctionDefinition, isString, isTopLevelDefinition } from '../utils/node-types';
import { isBuiltin } from '../utils/builtins';
import { md } from '../utils/markdown-builder';
import { flattenNested } from '../utils/flatten';
export type FishAliasInfoType = {
name: string;
value: string;
prefix: 'builtin' | 'command' | '';
wraps: string | null;
hasEquals: boolean;
};
export namespace FishAlias {
/**
* Checks if a node is an alias command.
*/
export function isAlias(node: SyntaxNode): boolean {
return isCommandWithName(node, 'alias');
}
/**
* Extracts the alias name and value from a SyntaxNode representing an alias command.
* Handles both formats:
* - alias name=value
* - alias name value
*/
export function getInfo(node: SyntaxNode): {
name: string;
value: string;
prefix: 'builtin' | 'command' | '';
wraps: string | null;
hasEquals: boolean;
} | null {
if (!isCommandWithName(node, 'alias')) return null;
const firstArg = node.firstNamedChild?.nextNamedSibling;
if (!firstArg) return null;
let name: string;
let value: string;
let hasEquals: boolean;
// Handle both alias formats
if (firstArg.text.includes('=')) {
// Format: alias name=value
const [nameStr, ...valueParts] = firstArg.text.split('=');
// Return null if name or value is empty
if (!nameStr || valueParts.length === 0) return null;
name = nameStr;
value = valueParts.join('=').replace(/^['"]|['"]$/g, '');
hasEquals = true;
} else {
// Format: alias name value
const valueNode = firstArg.nextNamedSibling;
if (!valueNode) return null;
name = firstArg.text;
value = valueNode.text.replace(/^['"]|['"]$/g, '');
hasEquals = false;
}
// Determine prefix for recursive command prevention
const words = value.split(/\s+/);
const firstWord = words.at(0);
const lastWord = words.at(-1);
// Determine prefix for recursive command prevention
let prefix: 'builtin' | 'command' | '' = '';
if (firstWord === name) {
prefix = isBuiltin(name) ? 'builtin' : 'command';
}
// Determine if we should include wraps
// Do not wrap if alias foo 'foo xyz' or alias foo 'sudo foo'
const shouldWrap = firstWord !== name && lastWord !== name;
const wraps = shouldWrap ? value : null;
return {
name,
value,
prefix,
wraps,
hasEquals,
};
}
/**
* Converts a SyntaxNode representing an alias command into a function definition.
* The function definition includes:
* - function name
* - optional --wraps flag
* - description
* - function body
*/
export function toFunction(node: SyntaxNode): string | null {
const aliasInfo = getInfo(node);
if (!aliasInfo) return null;
const { name, value, prefix, wraps, hasEquals } = aliasInfo;
// Escape special characters in the value for both the wraps and description
const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
// Build the description string that matches fish's alias format
const description = hasEquals ?
`alias ${name}=${escapedValue}` :
`alias ${name} ${escapedValue}`;
// Build the function components
const functionParts = [
`function ${name}`,
wraps ? `--wraps='${escapedValue}'` : '',
`--description '${description}'`,
].filter(Boolean).join(' ');
// Build the function body with optional prefix
const functionBody = prefix ?
` ${prefix} ${value} $argv` :
` ${value} $argv`;
// Combine all parts
return [
functionParts,
functionBody,
'end',
].join('\n');
}
export function getNameRange(node: SyntaxNode) {
const aliasInfo = getInfo(node);
if (!aliasInfo) return null;
const nameNode = node.firstNamedChild?.nextNamedSibling;
if (!nameNode) return null;
if (!aliasInfo.hasEquals) {
return getRange(nameNode);
}
const nameLength = aliasInfo.name.length;
return {
start: {
line: nameNode.startPosition.row,
character: nameNode.startPosition.column,
},
end: {
line: nameNode.endPosition.row,
character: nameNode.startPosition.column + nameLength,
},
};
}
export function buildDetail(node: SyntaxNode) {
const aliasInfo = getInfo(node);
if (!aliasInfo) return null;
const { name } = aliasInfo;
const detail = toFunction(node);
if (!detail) return null;
return [
`(${md.italic('alias')}) ${name}`,
md.separator(),
md.codeBlock('fish', node.text),
md.separator(),
md.codeBlock('fish', detail),
].join('\n');
}
export function toFishDocumentSymbol(
child: SyntaxNode,
parent: SyntaxNode,
document: LspDocument,
children: FishSymbol[] = [],
): FishSymbol | null {
const aliasInfo = getInfo(parent);
if (!aliasInfo) return null;
const { name } = aliasInfo;
const detail = toFunction(parent);
if (!detail) return null;
const selectionRange = getNameRange(parent);
if (!selectionRange) return null;
const detailText = buildDetail(parent);
if (!detailText) return null;
return FishSymbol.fromObject({
name,
document,
uri: document.uri,
node: parent,
focusedNode: child,
detail: detailText,
fishKind: 'ALIAS',
range: getRange(parent),
selectionRange,
scope: getScope(document, child),
children,
});
}
}
function getAliasScopeModifier(document: LspDocument, node: SyntaxNode) {
const autoloadType = document.getAutoloadType();
switch (autoloadType) {
case 'conf.d':
case 'config':
return isTopLevelDefinition(node) ? 'global' : 'local';
case 'functions':
return 'local';
default:
return 'local';
}
}
function getScopeNode(node: SyntaxNode) {
if (node.parent) return node.parent;
return findParentWithFallback(node, isFunctionDefinition);
}
/**
* TODO: remove this function from ../utils/node-types.ts `isAliasName`
* checks if a node is the firstNamedChild of an alias command
*
* alias ls='ls -G'
* ^-- cursor is here
*
* alias cls 'command ls'
* ^-- cursor is here
*/
export function isAliasDefinitionName(node: SyntaxNode) {
if (isString(node) || isConcatenation(node)) return false;
if (!node.parent) return false;
// concatenated node is an alias with `=`
const isConcatenated = isConcatenation(node.parent);
// if the parent is a concatenation node, then move up to it's parent
let parentNode = node.parent;
// if that is the case, then we need to move up 1 more parent
if (isConcatenated) parentNode = parentNode.parent as SyntaxNode;
if (!parentNode || !isCommandWithName(parentNode, 'alias')) return false;
// since there is two possible cases, handle concatenated and non-concatenated differently
const firstChild = isConcatenated
? parentNode.firstNamedChild
: parentNode.firstChild;
// skip `alias` named node, since it's not the alias name
if (firstChild && firstChild.equals(node)) return false;
const args = parentNode.childrenForFieldName('argument');
// first element is args is the alias name
const aliasName = isConcatenated
? args.at(0)?.firstChild
: args.at(0);
return !!aliasName && aliasName.equals(node);
}
export function isAliasDefinitionValue(node: SyntaxNode) {
if (!node.parent) return false;
// concatenated node is an alias with `=`
const isConcatenated = isConcatenation(node.parent);
// if the parent is a concatenation node, then move up to it's parent
let parentNode = node.parent;
// if that is the case, then we need to move up 1 more parent
if (isConcatenated) parentNode = parentNode.parent as SyntaxNode;
if (!parentNode || !isCommandWithName(parentNode, 'alias')) return false;
// since there is two possible cases, handle concatenated and non-concatenated differently
const firstChild = isConcatenated
? parentNode.firstNamedChild?.nextNamedSibling
: parentNode.firstChild;
// skip `alias` named node, since it's not the alias name
if (firstChild && firstChild.equals(node)) return false;
const args = flattenNested(...parentNode.childrenForFieldName('argument'))
.filter(a => a.isNamed);
// first element is args is the alias name
// logger.debug('alias args', args.map(a => a.text));
const aliasValue = args.at(-1);
return !!aliasValue && aliasValue.equals(node);
}
export function processAliasCommand(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []) {
const modifier = getAliasScopeModifier(document, node);
const scopeNode = getScopeNode(node);
const definitionNode = node.firstNamedChild!;
const info = FishAlias.getInfo(node);
const detail = FishAlias.buildDetail(node);
const nameRange = FishAlias.getNameRange(node);
if (!info || !detail) return [];
return [
FishSymbol.fromObject({
name: info.name,
node,
focusedNode: definitionNode,
range: getRange(node),
selectionRange: nameRange || getRange(definitionNode),
fishKind: 'ALIAS',
document,
uri: document.uri,
detail,
scope: DefinitionScope.create(scopeNode, modifier),
children,
}),
];
}
================================================
FILE: src/parsing/argparse.ts
================================================
import path, { dirname } from 'path';
import { SyntaxNode } from 'web-tree-sitter';
import { FishSymbol } from './symbol';
import { LspDocument } from '../document';
import { analyzer } from '../analyze';
import { getRange } from '../utils/tree-sitter';
import { DefinitionScope, ScopeTag } from '../utils/definition-scope';
import { findOptions, isMatchingOption, Option } from './options';
import { isCommandWithName, isEndStdinCharacter, isString, isEscapeSequence, isVariableExpansion, isCommand, isInvalidVariableName, findParentWithFallback, isFunctionDefinition, isOption } from '../utils/node-types';
import { SyncFileHelper } from '../utils/file-operations';
import { pathToUri, uriToPath } from '../utils/translation';
import { workspaceManager } from '../utils/workspace-manager';
import { logger } from '../logger';
export const ArgparseOptions = [
Option.create('-n', '--name').withValue(),
Option.create('-x', '--exclusive').withValue(),
Option.create('-N', '--min-args').withValue(),
Option.create('-X', '--max-args').withValue(),
Option.create('-U', '--move-unknown'),
Option.create('-S', '--strict-longopts'),
Option.long('--unknown-arguments').withValue(),
Option.create('-i', '--ignore-unknown'),
Option.create('-s', '--stop-nonopt'),
Option.create('-h', '--help'),
];
const ArgparseOptsWithValues = ArgparseOptions.filter(opt =>
opt.equalsRawOption('-n', '--name') ||
opt.equalsRawOption('-x', '--exclusive') ||
opt.equalsRawOption('-N', '--min-args') ||
opt.equalsRawOption('-X', '--max-args') ||
opt.equalsRawOption('--unknown-arguments'),
);
const isBefore = (a: SyntaxNode, b: SyntaxNode) => a.startIndex < b.startIndex;
export function findArgparseOptions(node: SyntaxNode) {
if (isCommandWithName(node, 'argparse')) return undefined;
const endChar = node.children.find(node => isEndStdinCharacter(node));
if (!endChar) return undefined;
const nodes = node.childrenForFieldName('argument')
.filter(n => !isEscapeSequence(n) && isBefore(n, endChar))
.filter(n => !isVariableExpansion(n) || n.type !== 'variable_name');
return findOptions(nodes, ArgparseOptions);
}
/**
* Utility to ensure that args for `argparse` option variable definitions exclude
* argparse's optspec nodes, which can be in the form of:
* • `-n foo`, `--name foo`
* • `-x g,U`, `--exclusive=g,U`
* • `--ignore-unknown`, `--stop-nonopt`
* • `--unknown-arguments=KIND`
* • `-N 1`, `--min-args=1` , `--max-args=2` , '-X 2'
*
* Backtrack using the current node, to check if the previous node is an `argparse` switch
* that would inidicate the current node is a value for that switch, and
* should not be included as an `argparse` definition name.
*
* @example
* ```fish
* argparse -n=foo -x g,U --ignore-unknown --stop-nonopt h/help 'n/name=?' 'x/exclusive' -- $argv
* # ^^^^^^ ^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^ skipped via (check 1)
* # ^^^ skipped via (check 2)
* ```
*/
function isArgparseOptionSpecifier(node: SyntaxNode) {
// Check 1
if (isOption(node)) return true;
// Check 2
const previousNode = node.previousSibling;
if (!previousNode) return false;
// we return false here to indicate that we should not skip this node
// because the value for the previous node was provided using `--flag=value` syntax
if (previousNode.text.includes('=')) return false;
// don't skip previous nodes when the previous node is of the form:
// ```fish
// argparse -N 1 --max-args 2
// # ^ ^
// # Both of these nodes are excluded
// ```
return ArgparseOptsWithValues.some((option) => isMatchingOption(previousNode, option));
// return isMatchingOption(previousNode, Option.create('-n', '--name')) ||
// isMatchingOption(previousNode, Option.create('-x', '--exclusive')) ||
// isMatchingOption(previousNode, Option.create('-N', '--min-args')) ||
// isMatchingOption(previousNode, Option.create('-X', '--max-args')) ||
// isMatchingOption(previousNode, Option.long('--unknown-arguments'))
}
function isInvalidArgparseName(node: SyntaxNode) {
if (isEscapeSequence(node) || isCommand(node) || isInvalidVariableName(node)) return true;
if (isArgparseOptionSpecifier(node)) return true;
if (isVariableExpansion(node) || node.type === 'variable_name') return true;
// fixup the text, so we ignore '/" characters surrounding the flag names,
let text = node.text.trim();
if (isString(node)) {
text = text.slice(1, -1);
}
// ignore anything after an `=` character since that would not be part of the variable name
text = text.slice(0, text.indexOf('=') || -1);
// incase parser missed one of these cases, we do a final check to see if the text includes
// any characters that would be invalid for an argparse variable definition
// (e.g., command substitutions, variable expansions)
if (text.includes('(') || text.includes('$')) return true;
return false;
}
/**
* Find the names of the `argparse` definitions in a given node.
* Example:
* argparse -n foo -x g,U --ignore-unknown --stop-nonopt h/help 'n/name=?' 'x/exclusive' -- $argv
* ^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^^
* Notice that the nodes that are matches can be strings
*
*
*/
export function findArgparseDefinitionNames(node: SyntaxNode): SyntaxNode[] {
// check if the node is a 'argparse' command
if (!node || !isCommandWithName(node, 'argparse')) return [];
// check if the node has a '--' token
const endChar = node.children.find(node => isEndStdinCharacter(node));
if (!endChar) return [];
// get the children of the node that are not options and before the endChar (currently skips variables)
const nodes = node.childrenForFieldName('argument')
.filter(n => !isEscapeSequence(n) && isBefore(n, endChar))
.filter(n => !isInvalidArgparseName(n));
const { remaining } = findOptions(nodes, ArgparseOptions);
return remaining;
}
/**
* Checks if a node is an `argparse` definition variable
* NOTE: if the node in question is a variable expansion, it will be skipped.
* ```fish
* argparse --max-args=2 --ignore-unknown --stop-nonopt h/help 'n/name=?' 'x/exclusive' -- $argv
* ```
* Would return true for the following SyntaxNodes passed in:
* - `h/help`
* - `n/name=?`
* - `x/exclusive`
* @param node The node to check, where it's parent isCommandWithName(parent, 'argparse'), and it's not a switch
* @returns true if the node is an argparse definition variable (flags for the `argparse` command with be skipped)
*/
export function isArgparseVariableDefinitionName(node: SyntaxNode) {
if (!node.parent || !isCommandWithName(node.parent, 'argparse')) return false;
const children = findArgparseDefinitionNames(node.parent);
return !!children.some(n => n.equals(node));
}
export function convertNodeRangeWithPrecedingFlag(node: SyntaxNode) {
const range = getRange(node);
if (node.text.startsWith('_flag_')) {
range.start = {
line: range.start.line,
character: range.start.character + 6,
};
}
return range;
}
export function isGlobalArgparseDefinition(document: LspDocument, symbol: FishSymbol) {
if (!symbol.isArgparse() || !symbol.isFunction()) return false;
let parent = symbol.parent;
if (symbol.isFunction() && symbol.isGlobal()) {
parent = symbol;
}
if (parent && parent?.isFunction()) {
const functionName = parent.name;
if (document.getAutoLoadName() !== functionName) {
return false;
}
const filepath = document.getFilePath();
// const workspaceDirectory = workspaces.find(ws => ws.contains(filepath) || ws.path === filepath)?.path || dirname(dirname(filepath));
const workspaceDirectory = workspaceManager.findContainingWorkspace(document.uri)?.path || dirname(dirname(filepath));
const completionFile = document.getAutoloadType() === 'conf.d' || document.getAutoloadType() === 'config'
? document.getFilePath()
: path.join(
workspaceDirectory,
'completions',
document.getFilename(),
);
if (process.env.NODE_ENV !== 'test' && !SyncFileHelper.isFile(completionFile)) {
return false;
}
return analyzer.getFlatCompletionSymbols(pathToUri(completionFile)).length > 0;
}
return false;
}
/**
* This is really more of a utility to ensure that any document that would contain
* any references to completions for an autoloaded file, is parsed by the analyzer.
*/
export function getGlobalArgparseLocations(document: LspDocument, symbol: FishSymbol) {
if (isGlobalArgparseDefinition(document, symbol)) {
const filepath = uriToPath(document.uri);
const workspaceDirectory = workspaceManager.findContainingWorkspace(document.uri)?.path || dirname(dirname(filepath));
logger.log(
`Getting global argparse locations for symbol: ${symbol.name} in file: ${filepath}`,
{
filepath,
workspaceDirectory,
});
const completionFile = document.getAutoloadType() === 'conf.d' || document.getAutoloadType() === 'config'
? document.getFilePath()
: path.join(
workspaceDirectory,
'completions',
document.getFilename(),
);
if (process.env.NODE_ENV !== 'test' && !SyncFileHelper.isFile(completionFile)) {
logger.debug({
env: 'test',
message: `Completion file does not exist: ${completionFile}`,
});
return [];
}
logger.debug({
message: `Getting global argparse locations for symbol: ${symbol.name} in file: ${completionFile}`,
});
const completionLocations = analyzer
.getFlatCompletionSymbols(pathToUri(completionFile))
.filter(s => s.isNonEmpty())
.filter(s => s.equalsArgparse(symbol) || s.equalsCommand(symbol))
.map(s => s.toLocation());
logger.log(`Found ${completionLocations.length} global argparse locations for symbol: ${symbol.name}`, 'HERE');
// const containsOpt = analyzer.getNodes(pathToUri(completionFile)).filter(n => isCommandWithName(n, '__fish_contains_opt'));
return completionLocations;
}
logger.warning(`no global argparse locations found for symbol: ${symbol.name}`, 'HERE');
return [];
}
function getArgparseScopeModifier(document: LspDocument, _node: SyntaxNode): ScopeTag {
const autoloadType = document.getAutoloadType();
switch (autoloadType) {
case 'conf.d':
case 'config':
case 'functions':
return 'local';
default:
// return isTopLevelDefinition(node) ? 'global' : 'local';
return 'local';
}
}
export function getArgparseDefinitionName(node: SyntaxNode): string {
if (!node.parent || !isCommandWithName(node.parent, 'complete')) return '';
if (node.text) {
const text = `_flag_${node.text}`;
return text.replace(/-/, '_');
}
return '';
}
/**
* Checks if a syntax node is a completion argparse flag with a specific command name.
*
* On the input: `complete -c test -s h -l help -d 'show help info for the test command'`
* ^---- node is here
* A truthy result would be returned from the following function call:
*
* `isCompletionArgparseFlagWithCommandName(node, 'test', 'help')`
* ___
* @param node - The syntax node to check
* @param commandName - The command name to match against
* @param flagName - The flag name to match against
* @param opts - Optional configuration options
* @param opts.noCommandNameAllowed - When true, a completion without a `-c`/`--command` Option is allowed
* @param opts.discardIfContainsOptions - A list of options that, if present, will cause the match to be discarded
* @returns True if the node is a completion argparse flag with the specified command name
*/
export function isCompletionArgparseFlagWithCommandName(node: SyntaxNode, commandName: string, flagName: string, opts?: {
noCommandNameAllowed?: boolean;
discardIfContainsOptions?: Option[];
}) {
// make sure that the node we are checking is inside a completion definition
if (!node?.parent || !isCommandWithName(node.parent, 'complete')) return false;
// parent is the entire completion command
const parent = node.parent;
// check if any of the options to discard are seen
if (opts?.discardIfContainsOptions) {
for (const option of opts.discardIfContainsOptions) {
if (parent.children.some(c => option.matches(c))) {
return false;
}
}
}
// check if the command name is present in the completion
let completeCmdName: boolean = !!parent.children.find(c =>
c.previousSibling &&
isMatchingOption(c.previousSibling, Option.create('-c', '--command')) &&
c.text === commandName,
);
// if noCommandNameAllowed is true, and we don't have a command name yet
// update the completeCmdName to be true if the `-c`/`--command` option is not present
if (opts?.noCommandNameAllowed && !completeCmdName) {
completeCmdName = !parent.children.some(c =>
c.previousSibling &&
isMatchingOption(c.previousSibling, Option.create('-c', '--command')),
);
}
// Here we determine if which type of option we are looking for
const option = flagName.length === 1
? Option.create('-s', '--short')
: Option.create('-l', '--long');
// check if the option name is present in the completion
const completeFlagName: boolean = !!(
node.previousSibling &&
option.equals(node.previousSibling) &&
node.text === flagName
);
// return true if both the command name and option name
return completeCmdName && completeFlagName;
}
function createSelectionRange(node: SyntaxNode, flags: string[], flag: string, idx: number) {
const range = getRange(node);
const text = node.text;
const shortenedFlag = flag.replace(/^_flag_/, '');
if (flags.length === 2 && idx === 0) {
if (isString(node)) {
range.start = {
line: range.start.line,
character: range.start.character + 1,
};
range.end = {
line: range.start.line,
character: range.start.character - 1,
};
}
return {
start: range.start,
end: {
line: range.start.line,
character: range.start.character + shortenedFlag.length,
},
};
} else if (flags.length === 2 && idx === 1) {
return {
start: {
line: range.start.line,
character: range.start.character + text.indexOf('/') + 1,
},
end: {
line: range.end.line,
character: range.start.character + text.indexOf('/') + 1 + shortenedFlag.length,
},
};
} else if (flags.length === 1) {
if (isString(node)) {
return {
start: {
line: range.start.line,
character: range.start.character + 1,
},
end: {
line: range.start.line,
character: range.start.character + 1 + shortenedFlag.length,
},
};
} else {
return getRange(node);
}
}
return range;
}
// split the `h/help` into `h` and `help`
function splitSlash(str: string): string[] {
const results = str.split('/')
.map(s => s.trim().replace(/-/g, '_'));
const maxResults = results.length < 2 ? results.length : 2;
return results.slice(0, maxResults);
}
// get the flag variable names from the argparse commands
function getNames(flags: string[]) {
return flags.map(flag => {
return `_flag_${flag}`;
});
}
/**
* Process an argparse command and return all of the flag definitions as a `FishSymbol[]`
* @param document The LspDocument we are processing
* @param node The node we are processing, should be isCommandWithName(node, 'argparse')
* @param children The children symbols of the current FishSymbol's we are processing (likely empty)
* @returns An array of FishSymbol's that represent the flags defined in the argparse command
*/
export function processArgparseCommand(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []) {
const result: FishSymbol[] = [];
// get the scope modifier
const modifier = getArgparseScopeModifier(document, node);
const scopeNode = findParentWithFallback(node, (n) => isFunctionDefinition(n));
// array of nodes that are `argparse` flags
const focuesedNodes = findArgparseDefinitionNames(node);
// build the flags, and store them in the result array
for (const n of focuesedNodes) {
let flagNames = n.text;
if (!flagNames) continue;
// fixup the flag names for strings and concatenated flags
if (isString(n)) flagNames = flagNames.slice(1, -1);
if (flagNames.includes('=')) flagNames = flagNames.slice(0, flagNames.indexOf('='));
// split the text into corresponding flags and convert them to `_flag_` format
const seenFlags = splitSlash(flagNames);
const names = getNames(seenFlags);
// add all seenFlags to the `result: FishSymbol[]` array
const flags = names.map((flagName, idx) => {
const selectedRange = createSelectionRange(n, seenFlags, flagName, idx);
return FishSymbol.fromObject({
name: flagName,
node: node,
focusedNode: n,
fishKind: 'ARGPARSE',
document: document,
uri: document.uri,
detail: n.text,
range: getRange(n),
selectionRange: selectedRange,
scope: DefinitionScope.create(scopeNode, modifier),
children,
}).addAliasedNames(...names);
});
result.push(...flags);
}
return result;
}
================================================
FILE: src/parsing/barrel.ts
================================================
import * as SetParser from './set';
import * as ReadParser from './read';
import * as ForParser from './for';
import * as ArgparseParser from './argparse';
import * as AliasParser from './alias';
import * as ExportParser from './export';
import * as FunctionParser from './function';
import * as CompleteParser from './complete';
import * as OptionsParser from './options';
import * as SymbolParser from './symbol';
import * as EventParser from './emit';
import { SyntaxNode } from 'web-tree-sitter';
/**
* Internal SyntaxNode parsers for finding FishSymbol definitions
* of any `FishKindType`. These are marked as internal because
* ideally they will be exported through the `../utils/node-types.ts`
* file, which is where we want to isolate importing SyntaxNode
* checkers while using them throughout the code bases' files.
*/
/** @internal */
export const Parsers = {
set: SetParser,
read: ReadParser,
for: ForParser,
argparse: ArgparseParser,
function: FunctionParser,
complete: CompleteParser,
options: OptionsParser,
symbol: SymbolParser,
export: ExportParser,
event: EventParser,
};
/** @internal */
export const VariableDefinitionKeywords = [
'set',
'read',
'argparse',
'for',
'function',
'export',
];
/**
* @internal
* Checks if a node is a variable definition name.
* Examples of variable names include:
* - `set -g -x foo '...'` -> foo
* - `read -l bar baz` -> bar baz
* - `argparse h/help -- $argv` -> h/help
* - `for i in _ ` -> i
* - `export foo=bar` -> foo
*/
export function isVariableDefinitionName(node: SyntaxNode) {
return SetParser.isSetVariableDefinitionName(node) ||
ReadParser.isReadVariableDefinitionName(node) ||
ArgparseParser.isArgparseVariableDefinitionName(node) ||
ForParser.isForVariableDefinitionName(node) ||
FunctionParser.isFunctionVariableDefinitionName(node) ||
ExportParser.isExportVariableDefinitionName(node);
}
/**
* @internal
* Checks if a node is a function definition name.
* Examples of function names include:
* - `function baz; end;` -> baz
*/
export function isFunctionDefinitionName(node: SyntaxNode) {
return FunctionParser.isFunctionDefinitionName(node);
}
/**
* @internal
* Checks if a node is a alias definition name.
* - `alias foo '__foo'` -> foo
* - `alias bar='__bar'` -> bar
*/
export function isAliasDefinitionName(node: SyntaxNode) {
return AliasParser.isAliasDefinitionName(node);
}
/**
* @internal
* Checks if a node is a function variable definition name.
* - `emit event-name` -> event-name
*/
export function isEmittedEventDefinitionName(node: SyntaxNode) {
return EventParser.isEmittedEventDefinitionName(node);
}
/**
* @internal
* Checks if a node is a export definition name.
* - `export foo=__foo` -> foo
* - `export bar='__bar'` -> bar
*/
export function isExportVariableDefinitionName(node: SyntaxNode) {
return ExportParser.isExportVariableDefinitionName(node);
}
/**
* @internal
* Checks if a node is an `argparse` definition variable
* NOTE: if the node in question is a variable expansion, it will be skipped.
* ```fish
* argparse --max-args=2 --ignore-unknown --stop-nonopt h/help 'n/name=?' 'x/exclusive' -- $argv
* ```
* Would return true for the following SyntaxNodes passed in:
* - `h/help`
* - `n/name=?`
* - `x/exclusive`
* @param node The node to check, where it's parent isCommandWithName(parent, 'argparse'), and it's not a switch
* @returns true if the node is an argparse definition variable (flags for the `argparse` command with be skipped)
*/
export function isArgparseVariableDefinitionName(node: SyntaxNode) {
return ArgparseParser.isArgparseVariableDefinitionName(node);
}
/**
* @internal
* Checks if a node is a definition name.
* Definition names are variable names (read/set/argparse/function flags), function names (alias/function),
*/
export function isDefinitionName(node: SyntaxNode) {
return isVariableDefinitionName(node) || isFunctionDefinitionName(node) || isAliasDefinitionName(node);
}
/**
* @internal
*/
export const NodeTypes = {
isVariableDefinitionName: isVariableDefinitionName,
isFunctionDefinitionName: isFunctionDefinitionName,
isAliasDefinitionName: isAliasDefinitionName,
isDefinitionName: isDefinitionName,
isSetVariableDefinitionName: SetParser.isSetVariableDefinitionName,
isReadVariableDefinitionName: ReadParser.isReadVariableDefinitionName,
isForVariableDefinitionName: ForParser.isForVariableDefinitionName,
isExportVariableDefinitionName: ExportParser.isExportVariableDefinitionName,
isArgparseVariableDefinitionName: ArgparseParser.isArgparseVariableDefinitionName,
isFunctionVariableDefinitionName: FunctionParser.isFunctionVariableDefinitionName,
isMatchingOption: OptionsParser.isMatchingOption,
};
/**
* @internal
*/
export const ParsingDefinitionNames = {
isSetVariableDefinitionName: SetParser.isSetVariableDefinitionName,
isReadVariableDefinitionName: ReadParser.isReadVariableDefinitionName,
isForVariableDefinitionName: ForParser.isForVariableDefinitionName,
isArgparseVariableDefinitionName: ArgparseParser.isArgparseVariableDefinitionName,
isFunctionVariableDefinitionName: FunctionParser.isFunctionVariableDefinitionName,
isFunctionDefinitionName: FunctionParser.isFunctionDefinitionName,
isAliasDefinitionName: AliasParser.isAliasDefinitionName,
isExportDefinitionName: ExportParser.isExportVariableDefinitionName,
} as const;
type DefinitionNodeNameTypes = 'isDefinitionName' | 'isVariableDefinitionName' | 'isFunctionDefinitionName' | 'isAliasDefinitionName';
type DefinitionNodeChecker = (n: SyntaxNode) => boolean;
/** @internal */
export const DefinitionNodeNames: Record = {
isDefinitionName: isDefinitionName,
isVariableDefinitionName: isVariableDefinitionName,
isFunctionDefinitionName: isFunctionDefinitionName,
isAliasDefinitionName: isAliasDefinitionName,
};
/** @internal */
export * from './options';
/** @internal */
export const parsers = Object.keys(Parsers).map(key => Parsers[key as keyof typeof Parsers]);
/** @internal */
export {
SetParser,
ReadParser,
ForParser,
ArgparseParser,
AliasParser,
FunctionParser,
ExportParser,
CompleteParser,
OptionsParser,
SymbolParser,
};
================================================
FILE: src/parsing/bind.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { findOptions, Option } from './options';
import { findParentCommand, isCommandWithName, isFunctionDefinitionName } from '../utils/node-types';
export const BindOptions = [
Option.create('-f', '--function-names'),
Option.create('-K', '--key-names'),
Option.create('-L', '--list-modes'),
Option.create('-M', '--mode').withValue(),
Option.create('-m', '--new-mode').withValue(),
Option.create('-e', '--erase'),
Option.create('-a', '--all'),
Option.long('--preset').withAliases('--user'),
Option.create('-s', '--silent'),
Option.create('-h', '--help'),
];
/**
* Checks if a node is a bind command. `bind ...`
*/
export function isBindCommand(node: SyntaxNode) {
return isCommandWithName(node, 'bind');
}
/**
* Checks if a node is a bind command's key sequence.
* `bind -M insert ctrl-r ...` -> ctrl-r
*/
export function isBindKeySequence(node: SyntaxNode) {
const parent = findParentCommand(node);
if (!parent || !isBindCommand(parent)) {
return false;
}
const children = parent.namedChildren.slice(1);
const optionResults = findOptions(children, BindOptions);
const { remaining } = optionResults;
return remaining.at(0)?.equals(node);
}
/**
* Checks if a node is a bind command's function call, which
* is any argument after the key sequence && bind options on
* a `bind -M default ctrl-r cmd1 cmd2 cmd3` -> cmd1, cmd2, cmd3
*/
export function isBindFunctionCall(node: SyntaxNode) {
const parent = findParentCommand(node);
if (!parent || !isBindCommand(parent)) {
return false;
}
const children = parent.namedChildren.slice(1);
const optionResults = findOptions(children, BindOptions);
const { remaining } = optionResults;
const functionCalls = remaining.slice(1);
return functionCalls.some(child => isFunctionDefinitionName(child) && child.equals(node));
}
================================================
FILE: src/parsing/comments.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isComment } from '../utils/node-types';
import { analyzer } from '../analyze';
import { getChildNodes } from '../utils/tree-sitter';
import { LspDocument } from '../document';
export const INDENT_COMMENT_REGEX = /^#\s*@fish_indent(?::\s*(off|on)?)?$/;
export function isIndentComment(node: SyntaxNode): boolean {
if (!isComment(node)) return false;
return INDENT_COMMENT_REGEX.test(node.text.trim());
}
export interface IndentComment {
node: SyntaxNode;
indent: 'on' | 'off';
line: number;
}
export function parseIndentComment(node: SyntaxNode): IndentComment | null {
if (!isIndentComment(node)) return null;
const match = node.text.trim().match(INDENT_COMMENT_REGEX);
if (!match) return null;
return {
node,
indent: match[1] === 'off' ? 'off' : 'on',
line: node.startPosition.row,
};
}
export function processIndentComments(root: SyntaxNode): IndentComment[] {
const comments: IndentComment[] = [];
for (const node of getChildNodes(root)) {
if (isIndentComment(node)) {
const indentComment = parseIndentComment(node);
if (indentComment) {
comments.push(indentComment);
}
}
}
return comments;
}
export interface FormatRange {
start: number; // line number (0-based)
end: number; // line number (0-based)
}
export interface FormatRanges {
formatRanges: FormatRange[];
fullDocumentFormatting: boolean;
}
export function getEnabledIndentRanges(doc: LspDocument, rootNode?: SyntaxNode): FormatRanges {
let root = rootNode;
if (!root) {
root = analyzer.getRootNode(doc.uri);
if (!root) return { formatRanges: [], fullDocumentFormatting: true };
}
const comments = processIndentComments(root);
if (comments.length === 0) {
// No indent comments found - format entire document
return {
formatRanges: [{ start: 0, end: root.endPosition.row }],
fullDocumentFormatting: true,
};
}
const ranges: FormatRange[] = [];
let currentStart = 0; // Start formatting from beginning
let isCurrentlyEnabled = true; // Formatting is enabled by default
for (const comment of comments) {
if (comment.indent === 'off' && isCurrentlyEnabled) {
// End current formatting range
if (comment.line > currentStart) {
ranges.push({ start: currentStart, end: comment.line - 1 });
}
isCurrentlyEnabled = false;
} else if (comment.indent === 'on' && !isCurrentlyEnabled) {
// Start new formatting range
currentStart = comment.line + 1;
isCurrentlyEnabled = true;
}
}
// If we end with formatting enabled, add final range
if (isCurrentlyEnabled && currentStart <= root.endPosition.row) {
ranges.push({ start: currentStart, end: root.endPosition.row });
}
return {
formatRanges: ranges,
fullDocumentFormatting: false,
};
}
================================================
FILE: src/parsing/complete.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isCommand, isCommandWithName, isOption, isString } from '../utils/node-types';
import { FishString } from './string';
import { Flag, isMatchingOption, Option } from './options';
import { LspDocument } from '../document';
import { getChildNodes, getRange, pointToPosition } from '../utils/tree-sitter';
import { FishSymbol } from './symbol';
import { Location, Range } from 'vscode-languageserver';
import { logger } from '../logger';
import { extractCommands } from './nested-strings';
export const CompleteOptions = [
Option.create('-c', '--command').withValue(),
Option.create('-p', '--path'),
Option.create('-e', '--erase'),
Option.create('-s', '--short-option').withValue(),
Option.create('-l', '--long-option').withValue(),
Option.create('-o', '--old-option').withValue(),
Option.create('-a', '--arguments').withValue(),
Option.create('-k', '--keep-order'),
Option.create('-f', '--no-files'),
Option.create('-F', '--force-files'),
Option.create('-r', '--require-parameter'),
Option.create('-x', '--exclusive'),
Option.create('-d', '--description').withValue(),
Option.create('-w', '--wraps').withValue(),
Option.create('-n', '--condition').withValue(),
Option.create('-C', '--do-complete').withValue(),
Option.long('--escape').withValue(),
Option.create('-h', '--help'),
];
export function isCompletionCommandDefinition(node: SyntaxNode) {
return isCommandWithName(node, 'complete');
}
export function isMatchingCompletionFlagNodeWithFishSymbol(symbol: FishSymbol, node: SyntaxNode) {
if (!node?.parent || isCommand(node) || isOption(node)) return false;
const prevNode = node.previousNamedSibling;
if (!prevNode) return false;
if (symbol.isFunction()) {
if (isMatchingOption(
prevNode,
Option.create('-c', '--command'),
Option.create('-w', '--wraps'),
)) {
return symbol.name === node.text && !symbol.equalsNode(node);
}
if (isMatchingOption(
prevNode,
Option.create('-n', '--condition'),
Option.create('-a', '--arguments'),
)) {
return isString(node)
? extractCommands(node).some(cmd => cmd === symbol.name)
: node.text === symbol.name;
}
}
if (symbol.isArgparse()) {
if (isCompletionSymbol(node)) {
const completionSymbol = getCompletionSymbol(node);
return completionSymbol.equalsArgparse(symbol);
}
}
if (symbol.isVariable()) {
return node.text === symbol.name;
}
return false;
}
export function isCompletionDefinitionWithName(node: SyntaxNode, name: string, doc: LspDocument) {
if (node.parent && isCompletionCommandDefinition(node.parent)) {
const symbol = getCompletionSymbol(node.parent, doc);
return symbol?.commandName === name && isCompletionSymbol(node);
}
return false;
}
export function isCompletionSymbolShort(node: SyntaxNode) {
if (node.parent && isCompletionCommandDefinition(node.parent)) {
return node.previousSibling && isMatchingOption(node.previousSibling, Option.create('-s', '--short-option'));
}
return false;
}
export function isCompletionSymbolLong(node: SyntaxNode) {
if (node.parent && isCompletionCommandDefinition(node.parent)) {
return node.previousSibling && isMatchingOption(node.previousSibling, Option.create('-l', '--long-option'));
}
return false;
}
export function isCompletionSymbolOld(node: SyntaxNode) {
if (node.parent && isCompletionCommandDefinition(node.parent)) {
return node.previousSibling && isMatchingOption(node.previousSibling, Option.create('-o', '--old-option'));
}
return false;
}
export function isCompletionSymbol(node: SyntaxNode) {
return isCompletionSymbolShort(node)
|| isCompletionSymbolLong(node)
|| isCompletionSymbolOld(node);
}
type OptionType = '' | 'short' | 'long' | 'old';
export class CompletionSymbol {
constructor(
public optionType: OptionType = '',
public commandName: string = '',
public node: SyntaxNode | null = null,
public description: string = '',
public condition: string = '',
public requireParameter: boolean = false,
public argumentNames: string = '',
public exclusive: boolean = false,
public document?: LspDocument,
) {}
/**
* Initialize the VerboseCompletionSymbol with empty values.
*/
static createEmpty() {
return new CompletionSymbol();
}
/**
* util for building a VerboseCompletionSymbol
*/
static create({
optionType = '',
commandName = '',
node = null,
description = '',
condition = '',
requireParameter = false,
argumentNames = '',
exclusive = false,
}: {
optionType?: OptionType;
commandName?: string;
node?: SyntaxNode | null;
description?: string;
condition?: string;
requireParameter?: boolean;
argumentNames?: string;
exclusive?: boolean;
}) {
return new this(optionType, commandName, node, description, condition, requireParameter, argumentNames, exclusive);
}
/**
* If the node is not found, we don't have a valid VerboseCompletionSymbol.
*/
isEmpty() {
return this.node === null;
}
/**
* Type Guard that our node & its parent are defined,
* therefore we have found a valid VerboseCompletionSymbol.
*/
isNonEmpty(): this is CompletionSymbol & { node: SyntaxNode; parent: SyntaxNode; } {
return this.node !== null && this.parent !== null;
}
/**
* Getter (w/ type guarding) to retrieve the CompletionSymbol.node.parent
* Removes the pattern of null checking a CompletionSymbol.node.parent
*/
get parent() {
if (this.node) {
return this.node.parent;
}
return null;
}
/**
* Getter (w/ type guarding) to retrieve the CompletionSymbol.node.text
* Removes the pattern of null checking a CompletionSymbol.node
*/
get text(): string {
if (this.isNonEmpty()) {
return this.node.text;
}
return '';
}
/**
* Check if the option is a short option: `-s ` or `--short-option `.
*/
isShort() {
return this.optionType === 'short';
}
/**
* Check if the option is a long option: `-l ` or `--long-option `.
*/
isLong() {
return this.optionType === 'long';
}
/**
* Check if the option is an old option: `-o ` or `--old-option `.
*/
isOld() {
return this.optionType === 'old';
}
/**
* Check if one option is a pair of another option.
* ```fish
* complete -c foo -s h -l help # 'h' <--> 'help' are pairs
* ```
*/
isCorrespondingOption(other: CompletionSymbol) {
if (!this.isNonEmpty() || !other.isNonEmpty()) {
return false;
}
return this.parent.equals(other.parent)
&& this.commandName === other.commandName
&& this.optionType !== other.optionType;
}
/**
* Return the `-f`/`--flag`/`-flag` string
*/
toFlag() {
if (!this.isNonEmpty()) return '';
switch (this.optionType) {
case 'short':
case 'old':
return `-${this.node.text}`;
case 'long':
return `--${this.node.text}`;
default:
return '';
}
}
/**
* return the commandName and the flag as a string
*/
toUsage() {
if (!this.isNonEmpty()) {
return '';
}
return `${this.commandName} ${this.toFlag()}`;
}
/**
* return the usage, with the description in a trailing comment
*/
toUsageVerbose() {
if (!this.isNonEmpty()) {
return '';
}
return `${this.commandName} ${this.toFlag()} # ${this.description}`;
}
/**
* check if the symbol inside a globally defined `argparse o/opt -- $argv` matches
* this VerboseCompletionSymbol
*/
equalsArgparse(symbol: FishSymbol) {
if (symbol.fishKind !== 'ARGPARSE' || !symbol.parent) {
return false;
}
const commandName = symbol.parent.name;
const symbolName = symbol.argparseFlagName;
return this.commandName === commandName
&& this.node?.text === symbolName;
}
equalsCommand(symbol: FishSymbol) {
if (!symbol.isFunction()) {
return false;
}
const commandName = symbol.name;
return this.hasCommandName(commandName);
}
/**
* Check if our CompletionSymbol.node === the node passed in
*/
equalsNode(n: SyntaxNode) {
return this.node?.equals(n);
}
/**
* check if our CompletionSymbol.commandName === the commandName passed in
*/
hasCommandName(name: string) {
return this.commandName === name;
}
/**
* A test utility for easily getting a completion flag
*/
isMatchingRawOption(...opts: Flag[]) {
const flag = this.toFlag();
for (const opt of opts) {
if (flag === opt) {
return true;
}
}
return false;
}
/**
* utility to get the range of the node
*/
getRange(): Range {
if (this.isNonEmpty()) {
return getRange(this.node);
}
return null as never;
}
/**
* Create a Location from the current CompletionSymbol
*/
toLocation(): Location {
return Location.create(this.document?.uri || '', this.getRange());
}
toPosition(): { line: number; character: number; } | null {
if (this.isNonEmpty()) {
return pointToPosition(this.node.startPosition);
}
return null as never;
}
/**
* Alias for the `this.text` property. Helps with readability, when comparing Argparse FishSymbols, to the string representation of the option.
*
* ```fish
* complete -c foo -s h -l help
* # ^ ^^^^ are both our `text` properties, we can build a string representation of the argparse option `h/help`
* ```
*
* ```fish
* function foo
* argparse h/help -- $argv
* end
* ```
* Returns the string representation of the option, e.g. `-h`, `--help`, or `-h/--help`.
*/
toArgparseOpt(): string {
if (!this.isNonEmpty()) {
return '';
}
return this.text;
}
/**
* Example: { name: `help-msg` } -> `_flag_help_msg`
* Returns the variable name that argparse would create for this completion.
*/
toArgparseVariableName(): string {
const prefix = '_flag_';
const fixString = (str: string) => str.replace(/-/g, '_');
if (!this.isNonEmpty()) {
return '';
}
return prefix + fixString(this.text);
}
static is(obj: unknown): obj is CompletionSymbol {
if (!obj || typeof obj !== 'object') {
return false;
}
return obj instanceof CompletionSymbol
&& typeof obj.optionType === 'string'
&& typeof obj.commandName === 'string'
&& typeof obj.description === 'string'
&& typeof obj.condition === 'string'
&& typeof obj.requireParameter === 'boolean'
&& typeof obj.argumentNames === 'string';
}
}
export function isCompletionSymbolVerbose(node: SyntaxNode, doc?: LspDocument): boolean {
if (isCompletionSymbol(node) || !node.parent) {
return true;
}
if (node.parent && isCompletionCommandDefinition(node.parent)) {
const symbol = getCompletionSymbol(node, doc);
return symbol?.isNonEmpty() || false;
}
return false;
}
/**
* Create a VerboseCompletionSymbol from a SyntaxNode, for any SyntaxNode passed in.
* Calling this function will need to check if `result.isEmpty()` or `result.isNonEmpty()`
* @param node any syntax node, preferably one that is a child of a `complete` node (not required though)
* @returns {CompletionSymbol} `result.isEmpty()` when not found, `result.isNonEmpty()` when `isCompletionSymbolVerbose(node)` is found
*/
export function getCompletionSymbol(node: SyntaxNode, doc?: LspDocument): CompletionSymbol {
const result = CompletionSymbol.createEmpty();
if (!isCompletionSymbol(node) || !node.parent) {
return result;
}
switch (true) {
case isCompletionSymbolShort(node):
result.optionType = 'short';
break;
case isCompletionSymbolLong(node):
result.optionType = 'long';
break;
case isCompletionSymbolOld(node):
result.optionType = 'old';
break;
default:
break;
}
result.node = node;
const parent = node.parent;
const children = parent.childrenForFieldName('argument');
result.document = doc;
children.forEach((child, idx) => {
if (idx === 0) return;
if (isMatchingOption(child, Option.create('-r', '--require-parameter'))) {
result.requireParameter = true;
}
if (isMatchingOption(child, Option.create('-x', '--exclusive'))) {
result.exclusive = true;
}
const prev = child.previousSibling;
if (!prev) return;
if (isMatchingOption(prev, Option.create('-c', '--command'))) {
result.commandName = child.text;
}
if (isMatchingOption(prev, Option.create('-d', '--description'))) {
result.description = FishString.fromNode(child);
}
if (isMatchingOption(prev, Option.create('-n', '--condition'))) {
result.condition = child.text;
}
if (isMatchingOption(prev, Option.create('-a', '--arguments'))) {
result.argumentNames = child.text;
}
});
return result;
}
export function groupCompletionSymbolsTogether(
...symbols: CompletionSymbol[]
): CompletionSymbol[][] {
const storedSymbols: Set = new Set();
const groupedSymbols: CompletionSymbol[][] = [];
symbols.forEach((symbol) => {
if (storedSymbols.has(symbol.text)) {
return;
}
const newGroup: CompletionSymbol[] = [symbol];
const matches = symbols.filter((s) => s.isCorrespondingOption(symbol));
matches.forEach((s) => {
storedSymbols.add(s.text);
newGroup.push(s);
});
groupedSymbols.push(newGroup);
});
return groupedSymbols;
}
export function getGroupedCompletionSymbolsAsArgparse(groupedCompletionSymbols: CompletionSymbol[][], argparseSymbols: FishSymbol[]): CompletionSymbol[][] {
const missingArgparseValues: CompletionSymbol[][] = [];
for (const symbolGroup of groupedCompletionSymbols) {
if (argparseSymbols.some(argparseSymbol => symbolGroup.find(s => s.equalsArgparse(argparseSymbol)))) {
logger.info({
message: 'Skipping symbol group that already has an argparse value',
symbolGroup: symbolGroup.map(s => s.toFlag()),
focusedSymbols: argparseSymbols.find(fs => symbolGroup.find(s => s.equalsArgparse(fs)))?.name,
});
continue;
}
missingArgparseValues.push(symbolGroup);
}
return missingArgparseValues;
}
export function processCompletion(document: LspDocument, node: SyntaxNode) {
const result: CompletionSymbol[] = [];
for (const child of getChildNodes(node)) {
if (isCompletionCommandDefinition(node)) {
const newSymbol = getCompletionSymbol(child, document);
if (newSymbol) result.push(newSymbol);
}
}
return result;
}
================================================
FILE: src/parsing/emit.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isCommandWithName, isFunctionDefinition } from '../utils/node-types';
import { FishSymbol } from './symbol';
import { DefinitionScope, ScopeTag } from '../utils/definition-scope';
import { LspDocument } from '../document';
import { getRange } from '../utils/tree-sitter';
import { md } from '../utils/markdown-builder';
import { unindentNestedSyntaxNode } from './symbol-detail';
import { findFunctionOptionNamedArguments } from './function';
/**
* Check if a SyntaxNode is an emitted/fired event definition name
* ```fish
* emit my_event_name
* # ^^^^^^^^^^^^^^ This is the emitted event definition name
* ```
* @param node - The SyntaxNode to check
* @return {boolean} - True if the node is an emitted event definition name, false otherwise
*/
export function isEmittedEventDefinitionName(node: SyntaxNode): boolean {
if (!node.parent || !node.isNamed) return false;
if (!isCommandWithName(node.parent, 'emit')) {
return false;
}
return !!(node.parent.namedChild(1) && node.parent.namedChild(1)?.equals(node));
}
/**
* Finds the emitted event definition name from a command node.
* ```fish
* emit my_event_name
* # ^^^^----------------- searches here
* # ^^^^^^^^^^^^^--- returns this node
* ```
*/
function findEmittedEventDefinitionName(node: SyntaxNode): SyntaxNode | undefined {
if (!isCommandWithName(node, 'emit')) return undefined;
if (node.namedChild(1)) return node.namedChild(1) || undefined;
return undefined;
}
/**
* Checks if a SyntaxNode is a generic event handler name, in a function definition
*
* ```fish
* function my_function --on-event my_event_name
* # ^^^^^^^^^^^^^^^^^^^^^^ This is the event handler definition name
* end
* ````
*
* @param node - The SyntaxNode to check
* @return {boolean} - True if the node is a generic event handler definition name, false otherwise
*/
export function isGenericFunctionEventHandlerDefinitionName(node: SyntaxNode): boolean {
if (!node.parent || !node.isNamed) return false;
// Check if the parent is a function definition with an event handler option
if (!isFunctionDefinition(node.parent)) return false;
const { eventNodes } = findFunctionOptionNamedArguments(node.parent);
return eventNodes.some(eventNode => eventNode.equals(node));
}
/**
* Processes an emit event command node and returns a FishSymbol representing the emitted event.
*
* Note: The processFunctionDefinition() function also handles building Event Symbols, but
* specifically creates them for `function ... --on-event NAME` (`fishKind === 'FUNCTION_EVENT'`),
* where as, this function creates symbols for `emit NAME` commands (`fishKind === 'EVENT'`).
*
* @param document - The LspDocument containing the node
* @param node - The SyntaxNode representing the emit command
* @param children - Optional array of child FishSymbols
*
* @returns {FishSymbol[]} - An array containing a FishSymbol for the emitted event
*/
export function processEmitEventCommandName(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []): FishSymbol[] {
const emittedEventNode = findEmittedEventDefinitionName(node);
if (!emittedEventNode) return [];
const eventName = emittedEventNode.text;
const parentCommand = node;
const scopeTag: ScopeTag = document.isAutoloaded()
? 'global'
: 'local';
return [
new FishSymbol({
name: eventName,
fishKind: 'EVENT',
node: parentCommand,
children,
document: document,
scope: DefinitionScope.create(node, scopeTag),
focusedNode: emittedEventNode,
range: getRange(parentCommand),
detail: [
`(${md.bold('event')}) ${md.inlineCode(eventName)}`,
md.separator(),
md.codeBlock('fish', [
'### emit/fire a generic event',
unindentNestedSyntaxNode(parentCommand),
].join('\n')),
md.separator(),
md.boldItalic('SEE ALSO:'),
' • Emit Events: https://fishshell.com/docs/current/cmds/emit.html',
' • Event Handling: https://fishshell.com/docs/current/language.html#event',
].join(md.newline()),
}),
];
}
================================================
FILE: src/parsing/equality-utils.ts
================================================
import { Location, Position, Range } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { FishSymbol } from './symbol';
import { isFunctionDefinition } from '../utils/node-types';
import { containsNode as nodeContainsNode, getRange } from '../utils/tree-sitter';
// === TYPES ===
type SymbolPair = {
a: FishSymbol;
b: FishSymbol;
};
type EqualityCheck = (pair: SymbolPair) => boolean;
type ScopeCheck = (pair: SymbolPair) => boolean;
// === RANGE & POSITION UTILITIES ===
// Check if two ranges are identical
export const rangesEqual = (range1: Range, range2: Range): boolean => {
return range1.start.line === range2.start.line &&
range1.start.character === range2.start.character &&
range1.end.line === range2.end.line &&
range1.end.character === range2.end.character;
};
// Check if a range contains a position
export const rangeContainsPosition = (range: Range, position: Position): boolean => {
const { line, character } = position;
const { start, end } = range;
if (line < start.line || line > end.line) return false;
if (line === start.line && character < start.character) return false;
if (line === end.line && character > end.character) return false;
return true;
};
// Check if a range contains a syntax node (by comparing line numbers only)
export const rangeContainsSyntaxNode = (range: Range, node: SyntaxNode): boolean => {
return range.start.line <= node.startPosition.row &&
range.end.line >= node.endPosition.row;
};
// === SYMBOL EQUALITY CHECKING ===
// Check if two symbols have matching names (including aliases)
const hasEqualNames: EqualityCheck = ({ a, b }) => {
if (a.name === b.name) return true;
return a.aliasedNames.includes(b.name) || b.aliasedNames.includes(a.name);
};
// Check if two symbols have identical ranges
const hasEqualRanges: EqualityCheck = ({ a, b }) => {
return rangesEqual(a.range, b.range) && rangesEqual(a.selectionRange, b.selectionRange);
};
// Check if two symbols have matching basic properties
const hasEqualBasicProperties: EqualityCheck = ({ a, b }) => {
return a.kind === b.kind &&
a.uri === b.uri &&
a.fishKind === b.fishKind;
};
// Main equality checker
export const equalSymbols = (symbolA: FishSymbol, symbolB: FishSymbol): boolean => {
const pair: SymbolPair = { a: symbolA, b: symbolB };
return hasEqualNames(pair) &&
hasEqualBasicProperties(pair) &&
hasEqualRanges(pair);
};
// === LOCATION EQUALITY ===
// Check if a symbol's location equals a given Location
export const symbolEqualsLocation = (symbol: FishSymbol, location: Location): boolean => {
return symbol.uri === location.uri &&
rangesEqual(symbol.selectionRange, location.range);
};
// === DEFINITION EQUALITY ===
// Check if two symbols represent the same definition
export const equalSymbolDefinitions = (symbolA: FishSymbol, symbolB: FishSymbol): boolean => {
return symbolA.name === symbolB.name &&
symbolA.kind === symbolB.kind &&
symbolA.uri === symbolB.uri &&
symbolContainsScope(symbolA, symbolB);
};
// === NODE EQUALITY ===
// Check if symbol equals a syntax node (with optional strict mode)
export const symbolEqualsNode = (symbol: FishSymbol, node: SyntaxNode, strict = false): boolean => {
if (strict) return symbol.focusedNode.equals(node);
return symbol.node.equals(node) || symbol.focusedNode.equals(node);
};
// === CONTAINMENT CHECKING ===
// Check if symbol's scope contains a syntax node
export const symbolScopeContainsNode = (symbol: FishSymbol, node: SyntaxNode): boolean => {
return symbol.scope.containsPosition(getRange(node).start);
};
// Check if symbol contains a syntax node (by range)
export const symbolContainsNode = (symbol: FishSymbol, node: SyntaxNode): boolean => {
return rangeContainsSyntaxNode(symbol.range, node);
};
// Check if symbol contains a position
export const symbolContainsPosition = (symbol: FishSymbol, position: Position): boolean => {
const { line, character } = position;
const { start, end } = symbol.selectionRange;
return start.line === line &&
start.character <= character &&
end.character >= character;
};
// === SCOPE CHECKING ===
// Check if two symbols have identical scope nodes
const haveSameScopeNode: ScopeCheck = ({ a, b }) => {
if (a.scopeTag === 'inherit' || b.scopeTag === 'inherit') {
return a.scopeContainsNode(b.node) || b.scopeContainsNode(a.node);
}
if (a.isLocal() && b.isLocal() && a.kind === b.kind && a.isVariable() && b.isVariable()) {
return a.scopeContainsNode(b.node) || b.scopeContainsNode(a.node);
}
return a.scope.scopeNode.equals(b.scope.scopeNode);
};
// Check if two symbols have compatible scope tags
const haveCompatibleScopeTags: ScopeCheck = ({ a, b }) => {
const scopeTags = [a.scope.scopeTag, b.scope.scopeTag];
// Special cases for scope compatibility
if (scopeTags.includes('inherit')) return true;
if (a.isLocal() && b.isLocal() && a.kind === b.kind && a.isVariable() && b.isVariable()) return true;
if (a.isGlobal() && b.isGlobal()) return true;
if (a.isLocal() && b.isLocal()) return true;
return a.scope.scopeTag === b.scope.scopeTag;
};
// Check if scopes are equal
const haveEqualScopes: ScopeCheck = ({ a, b }) => {
if (!haveSameScopeNode({ a, b }) || a.kind !== b.kind) return false;
return haveCompatibleScopeTags({ a, b });
};
// Check scope containment for variables specifically
const checkVariableScopeContainment: ScopeCheck = ({ a, b }) => {
if (!a.isVariable() || !b.isVariable()) return false;
// Both global variables
if (a.isGlobal() && b.isGlobal()) return true;
// if one of the tags is global and the other is local, they cannot contain each other
if (a.isGlobal() && b.isLocal() || a.isLocal() && b.isGlobal()) {
return false;
}
const isSameScope = haveSameScopeNode({ a, b });
const scopeContains = nodeContainsNode(a.scope.scopeNode, b.scope.scopeNode);
// Special handling for function definitions
if (isFunctionDefinition(a.scopeNode) && isFunctionDefinition(b.scopeNode)) {
return isSameScope;
}
return isSameScope || scopeContains;
};
// Check scope containment for general case (used by symbolContainsScope)
const checkGeneralScopeContainment: ScopeCheck = ({ a, b }) => {
if (!haveSameScopeNode({ a, b }) || a.kind !== b.kind) return false;
const scopeTags = [a.scope.scopeTag, b.scope.scopeTag];
// Handle inherit scope or local variables of same kind
if (scopeTags.includes('inherit') ||
a.isLocal() && b.isLocal() && a.kind === b.kind && a.isVariable() && b.isVariable()) {
if (isFunctionDefinition(a.scope.scopeNode) && isFunctionDefinition(b.scope.scopeNode)) {
return true;
}
return haveSameScopeNode({ a, b }) || nodeContainsNode(a.scope.scopeNode, b.scope.scopeNode);
}
// Handle global/local scope combinations
if (a.isGlobal() && b.isGlobal()) return true;
if (a.isLocal() && b.isLocal()) return true;
return a.scope.scopeTag === b.scope.scopeTag;
};
// Main scope containment checker
export const symbolContainsScope = (symbolA: FishSymbol, symbolB: FishSymbol): boolean => {
const pair: SymbolPair = { a: symbolA, b: symbolB };
// If scopes are equal, containment is true
if (haveEqualScopes(pair)) return true;
// Special handling for variables
if (symbolA.isVariable() && symbolB.isVariable()) {
return checkVariableScopeContainment(pair);
}
// General scope containment logic
return checkGeneralScopeContainment(pair);
};
// Main scope equality checker
export const equalSymbolScopes = (symbolA: FishSymbol, symbolB: FishSymbol): boolean => {
return haveEqualScopes({ a: symbolA, b: symbolB });
};
export const isFishSymbol = (obj: unknown): obj is FishSymbol => {
return typeof obj === 'object'
&& obj !== null
&& 'name' in obj
&& 'fishKind' in obj
&& 'uri' in obj
&& 'node' in obj
&& 'focusedNode' in obj
&& 'scope' in obj
&& 'children' in obj
&& typeof (obj as any).name === 'string'
&& typeof (obj as any).uri === 'string'
&& Array.isArray((obj as any).children);
};
export const fishSymbolNameEqualsNodeText = (symbol: FishSymbol, node: SyntaxNode): boolean => {
// Check if the symbol's name matches the text of the node
return symbol.name === node.text;
};
================================================
FILE: src/parsing/export.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { Range } from 'vscode-languageserver';
import { isCommandWithName, isConcatenation, isString } from '../utils/node-types';
import { LspDocument } from '../document';
import { FishSymbol } from './symbol';
import { DefinitionScope } from '../utils/definition-scope';
import { getRange } from '../utils/tree-sitter';
import { md } from '../utils/markdown-builder';
import { uriToReadablePath } from '../utils/translation';
import { Option } from './options';
/**
* Checks if a node is an export command definition
*/
export function isExportDefinition(node: SyntaxNode): boolean {
return isCommandWithName(node, 'export') && node.children.length >= 2;
}
/**
* Checks if a node is a variable name in an export statement (NAME=VALUE)
*/
export function isExportVariableDefinitionName(node: SyntaxNode): boolean {
if (isString(node) || isConcatenation(node)) return false;
if (!node.parent) return false;
// concatenated node is an export with `=`
const isConcatenated = isConcatenation(node.parent);
// if the parent is a concatenation node, then move up to it's parent
let parentNode = node.parent;
// if that is the case, then we need to move up 1 more parent
if (isConcatenated) parentNode = parentNode.parent as SyntaxNode;
if (!parentNode || !isCommandWithName(parentNode, 'export')) return false;
// since there is two possible cases, handle concatenated and non-concatenated differently
const firstChild = isConcatenated
? parentNode.firstNamedChild
: parentNode.firstChild;
// skip `export` named node, since it's not the alias name
if (firstChild && firstChild.equals(node)) return false;
const args = parentNode.childrenForFieldName('argument');
// first element is args is the export name
const exportName = isConcatenated
? args.at(0)?.firstChild
: args.at(0);
return !!exportName && exportName.equals(node);
}
type ExtractedExportVariable = {
name: string;
value: string;
nameRange: Range;
};
export function findVariableDefinitionNameNode(node: SyntaxNode): {
nameNode?: SyntaxNode;
valueNode?: SyntaxNode;
isConcatenation: boolean;
isValueString: boolean;
isNonEscaped: boolean;
} {
function getName(node: SyntaxNode): SyntaxNode | undefined {
let current: SyntaxNode | null = node;
while (current && current.type === 'concatenation') {
current = current.firstChild;
}
if (!current) return undefined;
return current;
}
function getValue(node: SyntaxNode): SyntaxNode | undefined {
let current: SyntaxNode | null = node;
while (current && current.type === 'concatenation') {
current = current.lastChild;
}
if (!current) return undefined;
return current;
}
let isConcatenation = false;
const nameNode = getName(node);
const valueNode = getValue(node);
const isValueString = !!valueNode && isString(valueNode);
const isNonEscaped = !!valueNode && !!nameNode && nameNode.equals(valueNode);
if (!nameNode || !valueNode) {
return {
nameNode,
valueNode,
isConcatenation: false,
isValueString,
isNonEscaped,
};
}
if (nameNode?.equals(valueNode)) {
return {
nameNode,
valueNode,
isConcatenation,
isValueString,
isNonEscaped,
};
}
isConcatenation = true;
return {
nameNode,
valueNode,
isConcatenation,
isValueString,
isNonEscaped,
};
}
/**
* Extracts variable information from an export definition
*/
export function extractExportVariable(node: SyntaxNode): ExtractedExportVariable | null {
const argument = node.firstChild?.nextNamedSibling;
if (!argument) {
return null;
}
// Split on the first '=' to get name and value
const [name, ...valueParts] = argument.text.split('=') as [string, ...string[]];
const value = valueParts.join('='); // Rejoin in case value contains '='
// Calculate range for just the name part
const nameStart = {
line: argument.startPosition.row,
character: argument.startPosition.column,
};
const nameEnd = {
line: nameStart.line,
character: nameStart.character + name.length,
};
return { name, value, nameRange: Range.create(nameStart, nameEnd) };
}
export function buildExportDetail(doc: LspDocument, commandNode: SyntaxNode, variableDefinitionNode: SyntaxNode) {
const commandText = commandNode.text;
const extracted = extractExportVariable(variableDefinitionNode);
if (!extracted) return '';
const { name, value } = extracted;
// Create a detail string with the command and variable definition
const detail = [
`${md.bold('(variable)')} ${md.inlineCode(name)}`,
`${md.italic('globally')} scoped, ${md.italic('exported')}`,
`located in file: ${md.inlineCode(uriToReadablePath(doc.uri))}`,
md.separator(),
md.codeBlock('fish', commandText),
md.separator(),
md.codeBlock('fish', `set -gx ${name} ${value}`),
].join(md.newline());
return detail;
}
/**
* Process an export command to create a FishSymbol
*/
export function processExportCommand(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []): FishSymbol[] {
if (!isExportDefinition(node)) return [];
// Get the second argument (the variable assignment part)
const args = node.namedChildren.slice(1); // Skip 'export' command name
if (args.length === 0) return [];
const argNode = args[0]!;
// Find the variable definition in the command's arguments
const found = findVariableDefinitionNameNode(argNode);
const varDefNode = found?.nameNode;
if (!found || !varDefNode) return [];
const {
name,
nameRange,
} = extractExportVariable(node) as ExtractedExportVariable;
// Get the scope - export always creates global exported variables
const scope = DefinitionScope.create(node.parent || node, 'global');
// The detail will be formatted by FishSymbol.setupDetail()
const detail = buildExportDetail(document, node, found.nameNode!);
// Create a FishSymbol for the export definition - using 'SET' fishKind
// since export is effectively an alias for 'set -gx'
return [
FishSymbol.fromObject({
name,
node,
focusedNode: varDefNode,
range: getRange(node),
selectionRange: nameRange,
fishKind: 'EXPORT', // Using SET since export is equivalent to 'set -gx'
document,
uri: document.uri,
detail,
scope,
// this is so that we always see that export variables are global and exported
options: [Option.create('-g', '--global'), Option.create('-x', '--export')],
children,
}),
];
}
================================================
FILE: src/parsing/for.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { FishSymbol } from './symbol';
import { DefinitionScope } from '../utils/definition-scope';
import { LspDocument } from '../document';
/**
* Checks if a SyntaxNode is a `for` loop definition name.
*
* ```fish
* for i in (seq 1 10);
* # ^_________________ `i` is the for loop definition name
* end
* ```
*
* @param node - The SyntaxNode to check (a 'variable_name' with a parent `for_statement`).
* @return {boolean} - True if the node is a `for` loop definition name, false otherwise.
*/
export function isForVariableDefinitionName(node: SyntaxNode): boolean {
if (node.parent && node.parent.type === 'for_statement') {
return !!node.parent.firstNamedChild &&
node.parent.firstNamedChild.type === 'variable_name' &&
node.parent.firstNamedChild.equals(node);
}
return false;
}
/**
* Create a FishSymbol for a `for` loop definition name.
*
* NOTE: `for ...` is not guaranteed to be processed into a FishSymbol,
* instead we only consider `for variable_name in ...` as a definition.
*
* @param document - The LspDocument containing the node.
* @param node - The SyntaxNode representing the `for` loop definition name.
* @param children - Optional array of FishSymbol children.
* @return An array containing a single FishSymbol for the `for` loop definition.
*/
export function processForDefinition(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []) {
const modifier = 'local';
const definitionNode = node.firstNamedChild!;
const definitionScope = DefinitionScope.create(node, modifier);
return [
FishSymbol.create(
definitionNode.text,
node,
definitionNode,
'FOR',
document,
document.uri,
node.text,
definitionScope,
[],
children,
),
];
}
================================================
FILE: src/parsing/function.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { findOptionsSet, Option, OptionValueMatch } from './options';
import { FishSymbol } from './symbol';
import { LspDocument } from '../document';
import { findParentWithFallback, isEscapeSequence, isNewline, isString } from '../utils/node-types';
import { PrebuiltDocumentationMap } from '../utils/snippets';
import { DefinitionScope } from '../utils/definition-scope';
import { isAutoloadedUriLoadsFunctionName } from '../utils/translation';
import { getRange } from '../utils/tree-sitter';
import { md } from '../utils/markdown-builder';
import { FishSymbolKindMap } from './symbol-kinds';
export const FunctionOptions = [
Option.create('-a', '--argument-names').withMultipleValues(),
Option.create('-d', '--description').withValue(),
Option.create('-w', '--wraps').withValue(),
Option.create('-e', '--on-event').withValue(),
Option.create('-v', '--on-variable').withValue(),
Option.create('-j', '--on-job-exit').withValue(),
Option.create('-p', '--on-process-exit').withValue(),
Option.create('-s', '--on-signal').withValue(),
Option.create('-S', '--no-scope-shadowing'),
Option.create('-V', '--inherit-variable').withValue(),
];
export const FunctionEventOptions = [
Option.create('-e', '--on-event').withValue(),
Option.create('-v', '--on-variable').withValue(),
Option.create('-j', '--on-job-exit').withValue(),
Option.create('-p', '--on-process-exit').withValue(),
Option.create('-s', '--on-signal').withValue(),
];
export const FunctionVariableOptions = FunctionOptions.filter(option => option.equalsRawOption('-a', '--argument-names', '-V', '--inherit-variable', '-v', '--on-variable'));
function isFunctionDefinition(node: SyntaxNode) {
return node.type === 'function_definition';
}
/**
* Util to find all the arguments of a function_definition node
*
* function foo -a bar baz -V foobar -d '...' -w '...' --on-event '...'
* ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
* all of these nodes would be returned in the SyntaxNode[] array
* @param node the function_definition node
* @returns SyntaxNode[] of all the arguments to the function_definition
*/
export function findFunctionDefinitionChildren(node: SyntaxNode) {
return node.childrenForFieldName('option').filter(n => !isEscapeSequence(n) && !isNewline(n));
}
/**
* Get argv definition for fish shell script files (non auto-loaded files)
*/
export function processArgvDefinition(document: LspDocument, node: SyntaxNode) {
if (!document.isAutoloaded() && node.type === 'program') {
return [
FishSymbol.fromObject({
name: 'argv',
node: node,
focusedNode: node.firstChild!,
fishKind: FishSymbolKindMap.variable,
document,
uri: document.uri,
detail: PrebuiltDocumentationMap.getByName('argv').pop()?.description || 'the list of arguments passed to the function',
scope: DefinitionScope.create(node, 'local'),
selectionRange: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
range: getRange(node),
options: [Option.create('-l', '--local')],
children: [],
}),
];
}
return [];
}
/**
* checks if a node is the function name of a function definition
* function foo
* ^--- here
*/
export function isFunctionDefinitionName(node: SyntaxNode) {
if (!node.parent || !isFunctionDefinition(node.parent)) return false;
if (isString(node)) return false;
return !!node.parent.firstNamedChild && node.parent.firstNamedChild.equals(node);
}
/**
* checks if a node is the variable name of a function definition
* function foo --argument-names bar baz --inherit-variable foobar
* ^ ^ ^
* | | |
* Could be any of these nodes above
* Currently doesn't check for `--on-variable`, because it should be inherited
*/
export function isFunctionVariableDefinitionName(node: SyntaxNode) {
if (!node.parent || !isFunctionDefinition(node.parent)) return false;
const { variableNodes } = findFunctionOptionNamedArguments(node.parent);
const definitionNode = variableNodes.find(n => n.equals(node));
return !!definitionNode && definitionNode.equals(node);
}
/**
* Find all the function_definition variables/events that are defined in the function header
*
* The `flagsSet` property contains all the nodes that were found to be variable names,
* with the flag that was used to define them.
*
* @param node the function_definition node
* @returns Object containing the defined SyntaxNode[] and OptionValueMatch[] flags set
*/
export function findFunctionOptionNamedArguments(node: SyntaxNode): {
variableNodes: SyntaxNode[];
eventNodes: SyntaxNode[];
flagsSet: OptionValueMatch[];
} {
const variableNodes: SyntaxNode[] = [];
const eventNodes: SyntaxNode[] = [];
const focused = node.childrenForFieldName('option').filter(n => !isEscapeSequence(n) && !isNewline(n));
const flagsSet = findOptionsSet(focused, FunctionOptions);
for (const flag of flagsSet) {
const { option, value: focused } = flag;
switch (true) {
case option.isOption('-a', '--argument-names'):
case option.isOption('-V', '--inherit-variable'):
// case option.isOption('-v', '--on-variable'):
variableNodes.push(focused);
break;
case option.isOption('-e', '--on-event'):
eventNodes.push(focused);
break;
default:
break;
}
}
return {
variableNodes,
eventNodes,
flagsSet,
};
}
/**
* Process a function definition node and return the corresponding FishSymbol[]
* for the function and its arguments. Includes argv as a child, along with any
* flags that create function scoped variables + any children nodes are stored as well.
*/
export function processFunctionDefinition(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []) {
if (!isFunctionDefinition(node)) return [];
const autoloadScope = isAutoloadedUriLoadsFunctionName(document);
const focusedNode = node.firstNamedChild!;
if (!focusedNode) return [];
const scopeModifier = autoloadScope(focusedNode) ? 'global' : 'local';
const scopeParentNode = findParentWithFallback(node, (n) => !n.equals(node) && isFunctionDefinition(n));
const focused = node.childrenForFieldName('option').filter(n => !isEscapeSequence(n) && !isNewline(n));
const functionSymbol = FishSymbol.create(
focusedNode.text,
node,
focusedNode,
FishSymbolKindMap.function,
document,
document.uri,
node.text,
DefinitionScope.create(scopeParentNode, scopeModifier),
findOptionsSet(focused, FunctionOptions)?.map(opt => opt.option) || [],
);
functionSymbol.addChildren(
FishSymbol.create(
'argv',
node,
node.firstNamedChild!,
FishSymbolKindMap.function_variable,
document,
document.uri,
PrebuiltDocumentationMap.getByName('argv').pop()?.description || 'the list of arguments passed to the function',
DefinitionScope.create(node, 'local'),
[Option.create('-l', '--local')],
),
);
if (!focused) return [functionSymbol];
const { flagsSet } = findFunctionOptionNamedArguments(node);
for (const flag of flagsSet) {
const { option, value: focused } = flag;
switch (true) {
case option.isOption('-a', '--argument-names'):
case option.isOption('-V', '--inherit-variable'):
// case option.isOption('-v', '--on-variable'):
functionSymbol.addChildren(
FishSymbol.create(
focused.text,
node,
focused,
FishSymbolKindMap.function_variable,
document,
document.uri,
focused.text,
DefinitionScope.create(node, 'local'),
[option],
),
);
break;
case option.isOption('-e', '--on-event'):
functionSymbol.addChildren(
FishSymbol.create(
focused.text,
node,
focused,
FishSymbolKindMap.function_event,
document,
document.uri,
[
`${md.boldItalic('Generic Event:')} ${md.inlineCode(focused.text)}`,
`${md.boldItalic('Event Handler:')} ${md.inlineCode(focusedNode.text)}`,
md.separator(),
md.codeBlock('fish', [
`### function definition: '${focusedNode.text}'`,
focusedNode?.parent?.text.toString(),
'',
'### Use the builtin `emit`, to fire this event:',
`emit ${focused.text.toString()}`,
`emit ${focused.text.toString()} with arguments # Specifies \`$argv\` to the event handler`,
].join('\n')),
md.separator(),
md.boldItalic('SEE ALSO:'),
' • Emit Events: https://fishshell.com/docs/current/cmds/emit.html',
' • Emit Handling: https://fishshell.com/docs/current/language.html#event',
].join(md.newline()),
DefinitionScope.create(node.tree.rootNode, 'global'),
[option],
),
);
break;
default:
break;
}
}
return [functionSymbol.addChildren(...children)];
}
================================================
FILE: src/parsing/inline-variable.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isCommand, isCommandName } from '../utils/node-types';
import { FishSymbol } from './symbol';
import { Position, Range } from 'vscode-languageserver';
import { LspDocument } from '../document';
import { getRange } from '../utils/tree-sitter';
import { DefinitionScope } from '../utils/definition-scope';
/**
* Parse command-scoped environment variable exports from Fish shell syntax.
*
* Examples:
* - `NVIM_APPNAME=nvim-lua nvim`
* - `DEBUG=1 npm test`
* - `PATH=/usr/local/bin:$PATH command`
*
* These are temporary environment variable assignments that only apply
* to the specific command being executed.
*/
/**
* Check if a command node contains inline environment variable assignments
*/
export function hasInlineVariables(commandNode: SyntaxNode): boolean {
if (!isCommand(commandNode)) return false;
// Look for assignment patterns in command arguments
for (let i = 0; i < commandNode.namedChildCount; i++) {
const child = commandNode.namedChild(i);
if (child && isInlineVariableAssignment(child)) {
return true;
}
}
return false;
}
/**
* Check if a node represents an inline variable assignment (VAR=value)
*/
export function isInlineVariableAssignment(node: SyntaxNode): boolean {
if (node.type !== 'word' && node.type !== 'concatenation') return false;
// Check if the text contains an assignment pattern
const text = node.text;
const assignmentMatch = text.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
return assignmentMatch !== null;
}
/**
* Extract variable name and value from an inline assignment node
*/
export function parseInlineVariableAssignment(node: SyntaxNode): { name: string; value: string; } | null {
if (!isInlineVariableAssignment(node)) return null;
const text = node.text;
const assignmentMatch = text.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (!assignmentMatch || !assignmentMatch[1] || assignmentMatch[2] === undefined) return null;
return {
name: assignmentMatch[1],
value: assignmentMatch[2],
};
}
/**
* Extract all inline variable assignments from a command node
*/
export function processInlineVariables(document: LspDocument, commandNode: SyntaxNode): FishSymbol[] {
if (!isCommand(commandNode)) return [];
const symbols: FishSymbol[] = [];
// Find the actual command name and variable assignments
// Inline variables come before the command name: VAR1=val1 VAR2=val2 command args
let commandNameNode: SyntaxNode | null = null;
const variableNodes: SyntaxNode[] = [];
for (let i = 0; i < commandNode.namedChildCount; i++) {
const child = commandNode.namedChild(i);
if (!child) continue;
if (isInlineVariableAssignment(child)) {
// Only collect variables that come before the command name
if (!commandNameNode) {
variableNodes.push(child);
}
} else if (!commandNameNode && isCommandName(child)) {
commandNameNode = child;
// Don't break here - continue to process remaining args if needed
}
}
// Create FishSymbol for each inline variable
for (const varNode of variableNodes) {
const assignment = parseInlineVariableAssignment(varNode);
if (!assignment) continue;
const startPos = Position.create(varNode.startPosition.row, varNode.startPosition.column);
// const endPos = Position.create(varNode.endPosition.row, varNode.endPosition.column);
// Calculate the range for just the variable name (before the =)
const nameEndColumn = varNode.startPosition.column + assignment.name.length;
const nameRange = Range.create(
startPos,
Position.create(varNode.startPosition.row, nameEndColumn),
);
// Create a basic scope for command-level variables
const scope = DefinitionScope.create(commandNode, 'local');
const symbol = FishSymbol.fromObject({
name: assignment.name,
document,
node: commandNode,
focusedNode: varNode,
detail: `Command environment variable: ${assignment.name}=${assignment.value}`,
fishKind: 'INLINE_VARIABLE',
range: getRange(varNode),
selectionRange: nameRange,
scope,
children: [],
});
symbols.push(symbol);
}
return symbols;
}
/**
* Find all inline variable assignments in a syntax tree
*/
export function findAllInlineVariables(document: LspDocument, tree: SyntaxNode): FishSymbol[] {
const symbols: FishSymbol[] = [];
function walkTree(node: SyntaxNode) {
if (isCommand(node) && hasInlineVariables(node)) {
symbols.push(...processInlineVariables(document, node));
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) {
walkTree(child);
}
}
}
walkTree(tree);
return symbols;
}
/**
* Get completion suggestions for inline variable names
* Returns common environment variables that are often used inline
*/
export function getInlineVariableCompletions(): string[] {
return [
'DEBUG',
'NODE_ENV',
'PATH',
'HOME',
'USER',
'SHELL',
'LANG',
'LC_ALL',
'TERM',
'DISPLAY',
'NVIM_APPNAME',
'EDITOR',
'PAGER',
'BROWSER',
'HTTP_PROXY',
'HTTPS_PROXY',
'NO_PROXY',
];
}
================================================
FILE: src/parsing/nested-strings.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { DocumentUri, Range, Location } from 'vscode-languageserver';
import { getRange } from '../utils/tree-sitter';
/**
* Configuration for command extraction
*/
export interface ExtractConfig {
/** Whether to parse command substitutions like $(cmd) */
readonly parseCommandSubstitutions: boolean;
/** Whether to parse parenthesized expressions like (cmd; and cmd2) */
readonly parseParenthesized: boolean;
/** Whether to remove fish keywords and operators */
readonly cleanKeywords: boolean;
}
/**
* Command reference with location information
*/
export interface CommandReference {
/** The extracted command name */
readonly command: string;
/** Location in the document */
readonly location: Location;
}
const DEFAULT_CONFIG: ExtractConfig = {
parseCommandSubstitutions: true,
parseParenthesized: true,
cleanKeywords: true,
};
/**
* Fish shell keywords and operators that should be filtered out
*/
const FISH_KEYWORDS = new Set([
'and', 'or', 'not', 'begin', 'end', 'if', 'else', 'switch', 'case',
'for', 'in', 'while', 'function', 'return', 'break', 'continue',
'set', 'test', 'true', 'false',
]);
const FISH_OPERATORS = new Set([
'&&', '||', '|', ';', '&', '>', '<', '>>', '<<', '>&', '<&',
'2>', '2>>', '2>&1', '1>&2', '/dev/null',
]);
/**
* Extract commands from fish shell string nodes
*/
export function extractCommands(
node: SyntaxNode,
config: ExtractConfig = DEFAULT_CONFIG,
): string[] {
if (!node.text?.trim()) return [];
const nodeText = node.text;
// Handle option arguments like --wraps=command
const optionCommand = parseOptionArgument(nodeText);
if (optionCommand) {
return [optionCommand];
}
const cleanedText = cleanQuotes(nodeText);
const commands = new Set();
// Always parse direct commands first
const directCommands = parseDirectCommands(cleanedText, config);
directCommands.forEach(cmd => commands.add(cmd));
// Parse command substitutions: $(cmd args)
if (config.parseCommandSubstitutions) {
const substitutionCommands = parseCommandSubstitutions(cleanedText);
substitutionCommands.forEach(cmd => commands.add(cmd));
}
// Parse parenthesized expressions: (cmd; and cmd2)
if (config.parseParenthesized) {
const parenthesizedCommands = parseParenthesizedExpressions(cleanedText);
parenthesizedCommands.forEach(cmd => commands.add(cmd));
}
return Array.from(commands).filter(cmd => cmd.length > 0);
}
/**
* Extract command references with precise location information
*/
export function extractCommandLocations(
node: SyntaxNode,
documentUri: DocumentUri,
config: ExtractConfig = DEFAULT_CONFIG,
): CommandReference[] {
if (!node.text?.trim()) return [];
const nodeRange = getRange(node);
const nodeText = node.text;
// Handle option arguments like --wraps=command
const optionCommand = parseOptionArgument(nodeText);
if (optionCommand) {
const offset = nodeText.indexOf(optionCommand);
return [{
command: optionCommand,
location: Location.create(
documentUri,
createPreciseRange(optionCommand, offset, nodeRange),
),
}];
}
const cleanedText = cleanQuotes(nodeText);
const quoteOffset = getQuoteOffset(nodeText);
return findCommandsWithOffsets(cleanedText, config)
.map(({ command, offset }) => ({
command,
location: Location.create(
documentUri,
createPreciseRange(command, offset + quoteOffset, nodeRange),
),
}));
}
/**
* Extract locations for a specific command name
*/
export function extractMatchingCommandLocations(
symbol: { name: string; },
node: SyntaxNode,
documentUri: DocumentUri,
config: ExtractConfig = DEFAULT_CONFIG,
): Location[] {
return extractCommandLocations(node, documentUri, config)
.filter(ref => ref.command === symbol.name)
.map(ref => ref.location);
}
/**
* Remove surrounding quotes and return offset adjustment
*/
function cleanQuotes(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"') ||
trimmed.startsWith("'") && trimmed.endsWith("'")) {
return trimmed.slice(1, -1);
}
return trimmed;
}
/**
* Get the offset adjustment for quotes
*/
function getQuoteOffset(input: string): number {
const trimmed = input.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"') ||
trimmed.startsWith("'") && trimmed.endsWith("'")) {
return 1; // Account for opening quote
}
return 0;
}
/**
* Find all commands with their precise offsets in the text
*/
function findCommandsWithOffsets(
text: string,
config: ExtractConfig,
): Array<{ command: string; offset: number; }> {
const results: Array<{ command: string; offset: number; }> = [];
// Always parse direct commands first
results.push(...findDirectCommandOffsets(text, config));
// Parse command substitutions
if (config.parseCommandSubstitutions) {
results.push(...findCommandSubstitutionOffsets(text));
}
// Parse parenthesized expressions
if (config.parseParenthesized) {
results.push(...findParenthesizedCommandOffsets(text));
}
return results;
}
/**
* Find command substitutions with offsets
*/
function findCommandSubstitutionOffsets(text: string): Array<{ command: string; offset: number; }> {
const results: Array<{ command: string; offset: number; }> = [];
const regex = /\$\(([^)]+)\)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const commandText = match[1];
const innerOffset = match.index + 2; // Skip '$('
if (commandText?.trim()) {
const firstCommand = getFirstCommand(commandText);
if (firstCommand) {
results.push({
command: firstCommand,
offset: innerOffset + commandText.indexOf(firstCommand),
});
}
}
}
return results;
}
/**
* Find parenthesized commands with offsets
*/
function findParenthesizedCommandOffsets(text: string): Array<{ command: string; offset: number; }> {
const results: Array<{ command: string; offset: number; }> = [];
const stack: number[] = [];
let start = -1;
for (let i = 0; i < text.length; i++) {
if (text[i] === '(') {
if (stack.length === 0) start = i;
stack.push(i);
} else if (text[i] === ')' && stack.length > 0) {
stack.pop();
if (stack.length === 0 && start !== -1) {
const innerText = text.slice(start + 1, i);
const innerOffset = start + 1;
if (innerText.trim()) {
const commands = extractCommandsFromText(innerText);
for (const command of commands) {
const commandOffset = innerText.indexOf(command);
if (commandOffset !== -1) {
results.push({
command,
offset: innerOffset + commandOffset,
});
}
}
}
start = -1;
}
}
}
return results;
}
/**
* Find direct commands with offsets
*/
function findDirectCommandOffsets(
text: string,
config: ExtractConfig,
): Array<{ command: string; offset: number; }> {
const results: Array<{ command: string; offset: number; }> = [];
const statements = text.split(/[;&|]+/);
let currentOffset = 0;
for (const statement of statements) {
const trimmedStatement = statement.trim();
const statementStart = text.indexOf(trimmedStatement, currentOffset);
if (trimmedStatement) {
const tokens = tokenizeStatement(trimmedStatement);
// Filter tokens if cleaning is enabled
const relevantTokens = config.cleanKeywords
? tokens.filter(token => !FISH_KEYWORDS.has(token) && !FISH_OPERATORS.has(token))
: tokens;
// Find offset for each relevant token
for (const token of relevantTokens) {
if (token && !isNumeric(token) && token.length > 1) {
const tokenOffset = trimmedStatement.indexOf(token);
if (tokenOffset !== -1) {
results.push({
command: token,
offset: statementStart + tokenOffset,
});
}
}
}
}
currentOffset = statementStart + statement.length;
}
return results;
}
/**
* Get the first command from a text string
*/
function getFirstCommand(text: string): string | null {
const tokens = tokenizeStatement(text);
return tokens.length > 0 && tokens[0] && tokens[0].length > 1 ? tokens[0] : null;
}
/**
* Extract individual commands from a text string
*/
function extractCommandsFromText(input: string, cleanKeywords = true): string[] {
const statements = input.split(/[;&|]+/)
.map(stmt => stmt.trim())
.filter(stmt => stmt.length > 0);
const commands: string[] = [];
for (const statement of statements) {
const tokens = tokenizeStatement(statement);
// Filter out fish keywords if enabled
const filteredTokens = cleanKeywords
? tokens.filter(token => !FISH_KEYWORDS.has(token) && !FISH_OPERATORS.has(token))
: tokens;
// Get all potential commands from the statement
for (const token of filteredTokens) {
if (token && !isNumeric(token) && token.length > 1) {
commands.push(token);
}
}
}
return commands;
}
/**
* Parse command substitutions
*/
function parseCommandSubstitutions(input: string): string[] {
const commands: string[] = [];
const regex = /\$\(([^)]+)\)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(input)) !== null) {
const commandText = match[1];
if (commandText?.trim()) {
commands.push(...extractCommandsFromText(commandText, true));
}
}
return commands;
}
/**
* Parse parenthesized expressions
*/
function parseParenthesizedExpressions(input: string): string[] {
const commands: string[] = [];
const stack: number[] = [];
let start = -1;
for (let i = 0; i < input.length; i++) {
if (input[i] === '(') {
if (stack.length === 0) start = i;
stack.push(i);
} else if (input[i] === ')' && stack.length > 0) {
stack.pop();
if (stack.length === 0 && start !== -1) {
const innerText = input.slice(start + 1, i);
if (innerText.trim()) {
commands.push(...extractCommandsFromText(innerText, true));
}
start = -1;
}
}
}
return commands;
}
/**
* Parse option arguments like --wraps=command, --command=cmd, etc.
*/
function parseOptionArgument(text: string): string | null {
// Match patterns like --wraps=command, --command=cmd, -c=cmd
const optionArgRegex = /^(?:-[a-zA-Z]|--[a-zA-Z][a-zA-Z0-9-]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_-]*)/;
const match = text.match(optionArgRegex);
if (match && match[1]) {
const command = match[1].trim();
// Only return if it looks like a valid command (not a number or single char)
if (command.length > 1 && !isNumeric(command)) {
return command;
}
}
return null;
}
/**
* Parse direct commands
*/
function parseDirectCommands(input: string, config: ExtractConfig): string[] {
return extractCommandsFromText(input, config.cleanKeywords);
}
/**
* Tokenize a statement respecting quotes
*/
function tokenizeStatement(statement: string): string[] {
const tokens: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < statement.length; i++) {
const char = statement[i];
if (!char) continue;
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
current += char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
current += char;
quoteChar = '';
} else if (!inQuotes && /\s/.test(char)) {
if (current.trim()) {
tokens.push(current.trim());
current = '';
}
} else {
current += char;
}
}
if (current.trim()) {
tokens.push(current.trim());
}
return tokens;
}
/**
* Create a precise range for a command at a specific offset
*/
function createPreciseRange(command: string, offset: number, nodeRange: Range): Range {
const startChar = nodeRange.start.character + offset;
return {
start: {
line: nodeRange.start.line,
character: startChar,
},
end: {
line: nodeRange.start.line,
character: startChar + command.length,
},
};
}
/**
* Check if a string is numeric
*/
function isNumeric(str: string): boolean {
return /^[0-9]+$/.test(str);
}
================================================
FILE: src/parsing/options.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isLongOption, isOption, isShortOption } from '../utils/node-types';
import { getRange } from '../utils/tree-sitter';
import * as LSP from 'vscode-languageserver';
/**
* Type definitions to allow us for checking single character (short) flags.
*/
type AlphaLowercaseChar = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
type AlphaUppercaseChar = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
type AlphaChar = AlphaLowercaseChar | AlphaUppercaseChar;
type DigitChar = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type ExtraChar = '?' | '!' | '@' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '+' | '=' | '{' | '}' | '[' | ']' | '|' | ';' | ':' | '"' | "'" | '<' | '>' | ',' | '.' | '/' | '\\' | '~' | '`';
type Character = AlphaChar | DigitChar | ExtraChar;
/**
* flags types using template literals to ensure the following type safetry:
* - ShortFlag is max a single character.
* - UnixFlag can be single or multiple characters, but must start with a single `-`.
* - LongFlag must start with `--` and can be multiple characters.
*/
export type ShortFlag = `-${Character}`;
export type UnixFlag = `-${string}`;
export type LongFlag = `--${string}`;
export type Flag = ShortFlag | UnixFlag | LongFlag;
/**
* Type Guard for converting a option string into the correct flag type.
*/
export const stringIsShortFlag = (str: string): str is ShortFlag => str.startsWith('-') && str.length === 2;
export const stringIsLongFlag = (str: string): str is LongFlag => str.startsWith('--');
export const stringIsUnixFlag = (str: string): str is UnixFlag => str.startsWith('-') && str.length > 2 && !str.startsWith('--');
export class Option {
public shortOptions: ShortFlag[] = [];
public unixOptions: UnixFlag[] = [];
public longOptions: LongFlag[] = [];
private requiresArgument: boolean = false;
private acceptsMultipleArguments: boolean = false;
private optionalArgument: boolean = false;
static create(shortOption: ShortFlag | '', longOption: LongFlag | ''): Option {
const option = new Option();
if (shortOption) {
option.shortOptions.push(shortOption);
}
if (longOption) {
option.longOptions.push(longOption);
}
return option;
}
static long(longOption: LongFlag): Option {
const option = new Option();
option.longOptions.push(longOption);
return option;
}
static short(shortOption: ShortFlag): Option {
const option = new Option();
option.shortOptions.push(shortOption);
return option;
}
static unix(unixOption: UnixFlag): Option {
const option = new Option();
option.unixOptions.push(unixOption);
return option;
}
static fromRaw(...str: string[]) {
const option = new Option();
for (const s of str) {
if (stringIsLongFlag(s)) {
option.longOptions.push(s);
} else if (stringIsShortFlag(s)) {
option.shortOptions.push(s as ShortFlag);
} else if (stringIsUnixFlag(s)) {
option.unixOptions.push(s as UnixFlag);
}
}
return option;
}
addUnixFlag(...options: UnixFlag[]): Option {
this.unixOptions.push(...options);
return this;
}
/**
* use addUnixFlag if you want to store unix flags in this object
*/
withAliases(...optionAlias: ShortFlag[] | LongFlag[] | string[]): Option {
for (const alias of optionAlias) {
if (stringIsLongFlag(alias)) {
this.longOptions.push(alias);
continue;
}
if (stringIsShortFlag(alias)) {
this.shortOptions.push(alias as ShortFlag);
continue;
}
}
return this;
}
isOption(shortOption: ShortFlag | '', longOption: LongFlag | ''): boolean {
if (shortOption) {
return this.shortOptions.includes(shortOption);
} else if (longOption) {
return this.longOptions.includes(longOption);
}
return false;
}
/**
* Mark this option as requiring a value
*/
withValue(): Option {
this.requiresArgument = true;
this.optionalArgument = false;
this.acceptsMultipleArguments = false;
return this;
}
/**
* Mark this option as accepting an optional value
*/
withOptionalValue(): Option {
this.optionalArgument = true;
this.requiresArgument = false;
this.acceptsMultipleArguments = false;
return this;
}
/**
* Mark this option as accepting multiple values
*/
withMultipleValues(): Option {
this.acceptsMultipleArguments = true;
this.requiresArgument = true;
this.optionalArgument = false;
return this;
}
/**
* Check if this option is a boolean switch (takes no value)
*
* A switch is a flag that does not require a value to be set. Another common name for
* this type of flag is a boolean flag.
*
* A switch is either enabled or disabled.
*
* You can pair this with `Option.equals(node) && Option.isSwitch()` to get the switch's found on sequence
*
* @returns true if the flag is a switch, if the flag requires a value to be set false.
*/
isSwitch(): boolean {
return !this.requiresArgument && !this.optionalArgument;
}
matchesValue(node: SyntaxNode): boolean {
if (this.isSwitch()) {
return false;
}
// Handle direct values (--option=value)
if (isOption(node) && node.text.includes('=')) {
const [flag] = node.text.split('=');
return this.matches({ ...node, text: flag } as SyntaxNode);
}
let prev: SyntaxNode | null = node.previousSibling;
// Handle values that follow the option
// const prev = node.previousSibling;
if (this.acceptsMultipleArguments) {
while (prev) {
if (isOption(prev) && !prev.text.includes('=')) {
return this.matches(prev);
}
if (isOption(prev)) return false;
prev = prev.previousSibling;
}
}
return !!prev && this.matches(prev);
}
/**
* Check if this option is present in the given node
*/
matches(node: SyntaxNode, checkWithEquals: boolean = true): boolean {
if (!isOption(node)) return false;
const nodeText = checkWithEquals && node.text.includes('=')
? node.text.slice(0, node.text.indexOf('='))
: node.text;
if (isLongOption(node)) {
return this.matchesLongFlag(nodeText);
}
if (isShortOption(node) && this.unixOptions.length >= 1) {
return this.matchesUnixFlag(nodeText);
}
if (isShortOption(node)) {
return this.matchesShortFlag(nodeText);
}
return false;
}
private matchesLongFlag(text: string): boolean {
if (!text.startsWith('--')) return false;
if (stringIsLongFlag(text)) {
return this.longOptions.includes(text);
}
return false;
}
private matchesUnixFlag(text: string): boolean {
if (stringIsUnixFlag(text) && text.length > 2) {
return this.unixOptions.includes(text);
}
return false;
}
private matchesShortFlag(text: string): boolean {
if (!text.startsWith('-') || text.startsWith('--')) return false;
// Handle combined short flags like "-abc"
const chars = text.slice(1).split('').map(char => `-${char}` as ShortFlag);
return chars.some(char => this.shortOptions.includes(char));
}
equals(node: SyntaxNode, allowEquals = false): boolean {
if (!isOption(node)) return false;
const text = allowEquals ? node.text.slice(0, node.text.indexOf('=')) : node.text;
if (isLongOption(node)) return this.matchesLongFlag(text);
if (isShortOption(node) && this.unixOptions.length >= 1) return this.matchesUnixFlag(text);
if (isShortOption(node)) return this.matchesShortFlag(text);
return false;
}
/**
* Warning, does not search oldUnixFlag
*/
equalsRawOption(...rawOption: Flag[]): boolean {
for (const option of rawOption) {
if (stringIsLongFlag(option) && this.longOptions.includes(option)) {
return true;
}
if (stringIsShortFlag(option) && this.shortOptions.includes(option)) {
return true;
}
}
return false;
}
equalsRawShortOption(...rawOption: ShortFlag[]): boolean {
return rawOption.some(option => this.shortOptions.includes(option));
}
equalsRawLongOption(...rawOption: LongFlag[]): boolean {
return rawOption.some(option => this.longOptions.includes(option));
}
equalsOption(other: Option): boolean {
const flags = other.getAllFlags() as Flag[];
return this.equalsRawOption(...flags);
}
findValueRangeAfterEquals(node: SyntaxNode): LSP.Range | null {
if (!isOption(node)) return null;
if (!node.text.includes('=')) return null;
const range = getRange(node);
if (!range) return null;
const equalsIndex = node.text.indexOf('=');
return LSP.Range.create(range.start.line, range.start.character + equalsIndex + 1, range.end.line, range.end.character);
}
/**
* Checks if a `-f/--flag` if a enabled (like a boolean switch) or if it is set with a value.
* ```
* function foo --description 'this is a description' --no-scope-shadowing; end;
* ```
* ^--isSet ^--isSet
* ^-- not set
* @param node to check if it is set
* @returns true if the node is set
*/
isSet(node: SyntaxNode): boolean {
if (isOption(node)) {
return this.equals(node) && this.isSwitch();
}
return this.matchesValue(node);
}
getAllFlags(): Array {
const result: string[] = [];
if (this.shortOptions) result.push(...this.shortOptions);
if (this.unixOptions) result.push(...this.unixOptions);
if (this.longOptions) result.push(...this.longOptions);
return result;
}
toString(): string {
return this.getAllFlags().join(', ');
}
toName(): string {
if (this.longOptions.length > 0) {
return this.longOptions[0]!.replace(/^--/, '');
}
if (this.unixOptions.length > 0) {
return this.unixOptions[0]!.replace(/^-/, '');
}
if (this.shortOptions.length > 0) {
return this.shortOptions[0]!.replace(/^-/, '');
}
return '';
}
}
export type OptionValueMatch = {
option: Option;
value: SyntaxNode;
};
export function findOptionsSet(nodes: SyntaxNode[], options: Option[]): OptionValueMatch[] {
const result: OptionValueMatch[] = [];
for (const node of nodes) {
const values = options.filter(o => o.isSet(node));
if (!values) {
continue;
}
values.forEach(option => result.push({ option, value: node }));
}
return result;
}
export function findOptions(nodes: SyntaxNode[], options: Option[]): { remaining: SyntaxNode[]; found: OptionValueMatch[]; unused: Option[]; } {
const remaining: SyntaxNode[] = [];
const found: OptionValueMatch[] = [];
const unused = Array.from(options);
for (const node of nodes) {
const values = options.filter(o => o.isSet(node));
if (values.length === 0 && !isOption(node)) {
remaining.push(node);
continue;
}
values.forEach(option => {
unused.splice(unused.indexOf(option), 1);
found.push({ option, value: node });
});
}
return {
remaining,
found,
unused,
};
}
/**
* Check if the node is a flag that is a part of the given option(s)
* @param node The node to check
* @param option The option(s) to check against
* @returns true if the node is a flag that is a part of the given option(s)
*/
export function isMatchingOption(node: SyntaxNode, ...option: Option[]): boolean {
if (!isOption(node)) return false;
for (const opt of option) {
if (opt.matches(node)) return true;
}
return false;
}
/**
* Check if the node is a flag that is a part of the given option(s)
*/
export function findMatchingOptions(node: SyntaxNode, ...options: Option[]): Option | undefined {
if (!isOption(node)) return;
return options.find((opt: Option) => opt.matches(node));
}
export function isMatchingOptionOrOptionValue(node: SyntaxNode, option: Option): boolean {
if (isMatchingOption(node, option)) {
return true;
}
const prevNode = node.previousNamedSibling;
if (prevNode?.text.includes('=')) {
return false;
}
if (prevNode && isMatchingOption(prevNode, option) && !isOption(node)) {
return true;
}
return false;
}
/**
* For any option passed in, check if the node is a value set on that option.
*
* ```fish
* function foo --wraps=a -w='b' --wraps 'c'; end; # matches a b c
* # ^^^^^^^^^ ^^^ ^^^
* complete -c foo -s s -l long --wraps bar # matches: foo, s, long, bar
* # ^^^ ^ ^^^^ ^^^
* ```
*
* Useful because we can match either case where tree-sitter parse a option's values
* • the option itself contains a value (e.g., `--wraps=a`, WHEN A `=` SIGN IS PRESENT)
* • the value, where the previous named silbing matches the option
*
* @param node The node to check
* @param options The options to check against
*
* @returns true if the node is a value set on any of the given option(s)
*/
export function isMatchingOptionValue(node: SyntaxNode, ...options: Option[]): boolean {
if (!node?.isNamed) return false;
if (isOption(node)) {
return options.some((option) => option.equals(node, true));
}
if (node.previousNamedSibling && isOption(node.previousNamedSibling)) {
return options.some(option => option.matchesValue(node));
}
return false;
}
================================================
FILE: src/parsing/read.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { Option, isMatchingOption, findMatchingOptions, findOptionsSet } from './options';
import { isOption, isCommandWithName, isString, isTopLevelDefinition, isProgram, isFunctionDefinition, hasParentFunction, findParentWithFallback, isScope, isInvalidVariableName } from '../utils/node-types';
import { FishSymbol, ModifierScopeTag, SetModifierToScopeTag } from './symbol';
import { LspDocument } from '../document';
import { DefinitionScope } from '../utils/definition-scope';
export const ReadOptions = [
Option.create('-U', '--universal'),
Option.create('-f', '--function'),
Option.create('-l', '--local'),
Option.create('-g', '--global'),
Option.create('-u', '--unexport'),
Option.create('-x', '--export'),
Option.create('-c', '--command').withValue(),
Option.create('-s', '--silent'),
Option.create('-p', '--prompt').withValue(),
Option.create('-P', '--prompt-str').withValue(),
Option.create('-R', '--right-prompt').withValue(),
Option.create('-S', '--shell'),
Option.create('-d', '--delimiter').withValue(),
Option.create('-n', '--nchars').withValue(),
Option.create('-t', '--tokenize'),
Option.create('-a', '--list').withAliases('--array'),
Option.create('-z', '--null'),
Option.create('-L', '--line'),
Option.create('-h', '--help'),
];
export const ReadModifiers = [
Option.create('-U', '--universal'),
Option.create('-f', '--function'),
Option.create('-l', '--local'),
Option.create('-g', '--global'),
];
/**
* checks if a node is the variable name of a read command
* read -g -x -p 'stuff' foo bar baz
* ^ ^ ^
* | | |
* cursor could be here
* invalid variable names include:
* read -
* ^
* |
* this would signify to read from stdin, not a variable
* read --
* ^^
* ||
* this would signify to stop parsing options
*/
export function isReadVariableDefinitionName(node: SyntaxNode) {
if (!node.parent || !isReadDefinition(node.parent)) return false;
const { definitionNodes } = findReadChildren(node.parent);
return !!definitionNodes.find(n => n.equals(node));
}
export function isReadDefinition(node: SyntaxNode) {
return isCommandWithName(node, 'read') && !node.children.some(child => isMatchingOption(child, Option.create('-q', '--query')));
}
function getFallbackModifierScope(document: LspDocument, node: SyntaxNode) {
const autoloadType = document.getAutoloadType();
switch (autoloadType) {
case 'conf.d':
case 'config':
case 'functions':
return isTopLevelDefinition(node) ? 'global' : hasParentFunction(node) ? 'function' : 'inherit';
case 'completions':
return isTopLevelDefinition(node) ? 'local' : hasParentFunction(node) ? 'function' : 'local';
case '':
return 'local';
default:
return 'inherit';
}
}
/**
* Find all the read command's children that are variable names
* @param node The node to check isCommandWithName(node, 'read')
* @returns nodes that are variable names and the modifier if seen
*/
export function findReadChildren(node: SyntaxNode): { definitionNodes: SyntaxNode[]; modifier: Option | undefined; } {
let modifier: Option | undefined = undefined;
const definitionNodes: SyntaxNode[] = [];
const allFocused: SyntaxNode[] = node.childrenForFieldName('argument')
.filter((n) => {
switch (true) {
case isMatchingOption(n, Option.create('-l', '--local')):
case isMatchingOption(n, Option.create('-f', '--function')):
case isMatchingOption(n, Option.create('-g', '--global')):
case isMatchingOption(n, Option.create('-U', '--universal')):
modifier = findMatchingOptions(n, ...ReadModifiers);
return false;
case isMatchingOption(n, Option.create('-c', '--command')):
return false;
case isMatchingOption(n.previousSibling!, Option.create('-d', '--delimiter')):
case isMatchingOption(n, Option.create('-d', '--delimiter')):
return false;
case isMatchingOption(n.previousSibling!, Option.create('-n', '--nchars')):
case isMatchingOption(n, Option.create('-n', '--nchars')):
return false;
case isMatchingOption(n.previousSibling!, Option.create('-p', '--prompt')):
case isMatchingOption(n, Option.create('-p', '--prompt')):
return false;
case isMatchingOption(n.previousSibling!, Option.create('-P', '--prompt-str')):
case isMatchingOption(n, Option.create('-P', '--prompt-str')):
return false;
case isMatchingOption(n.previousSibling!, Option.create('-R', '--right-prompt')):
case isMatchingOption(n, Option.create('-R', '--right-prompt')):
return false;
case isMatchingOption(n, Option.create('-s', '--silent')):
case isMatchingOption(n, Option.create('-S', '--shell')):
case isMatchingOption(n, Option.create('-t', '--tokenize')):
case isMatchingOption(n, Option.create('-u', '--unexport')):
case isMatchingOption(n, Option.create('-x', '--export')):
case isMatchingOption(n, Option.create('-a', '--list')):
case isMatchingOption(n, Option.create('-z', '--null')):
case isMatchingOption(n, Option.create('-L', '--line')):
return false;
default:
return true;
}
});
allFocused.forEach((arg) => {
if (isOption(arg)) return;
if (isString(arg)) return;
if (isInvalidVariableName(arg)) return;
definitionNodes.push(arg);
});
return {
definitionNodes,
modifier,
};
}
/**
* NOTE: `set` uses the parent of the command to determine the scope of the variable
* At a later date, consider which `scopeNode` should be used for both `set` and `read` commands
*/
function findReadParent(node: SyntaxNode, scopeModifier: ModifierScopeTag): SyntaxNode {
switch (scopeModifier) {
case 'global':
return findParentWithFallback(node, (n) => isProgram(n));
case 'inherit':
case 'function':
return findParentWithFallback(node, (n) => isFunctionDefinition(n) || isProgram(n));
case 'local':
default:
return findParentWithFallback(node, (n) => isScope(n));
}
}
/**
* Get all read command variable names as `FishSymbol[]`
*/
export function processReadCommand(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []) {
const result: FishSymbol[] = [];
const { definitionNodes, modifier } = findReadChildren(node);
const scopeModifier = modifier ? SetModifierToScopeTag(modifier) : getFallbackModifierScope(document, node);
const definitionParent = findReadParent(node, scopeModifier);
const definitionScope = DefinitionScope.create(definitionParent, scopeModifier);
const options = findOptionsSet([node], ReadOptions)?.map(opt => opt.option) || [];
for (const arg of definitionNodes) {
if (arg.text.startsWith('$')) continue;
result.push(FishSymbol.create(arg.text, node, arg, 'READ', document, document.uri, node.text, definitionScope, options, children));
}
return result;
}
================================================
FILE: src/parsing/reference-comparator.ts
================================================
import * as Locations from '../utils/locations';
import { SyntaxNode } from 'web-tree-sitter';
import { FishSymbol } from './symbol';
import { LspDocument } from '../document';
import { equalRanges, getChildNodes, getRange } from '../utils/tree-sitter';
import { isEmittedEventDefinitionName } from './emit';
import { findParentCommand, findParentFunction, isArgumentThatCanContainCommandCalls, isCommand, isCommandWithName, isEndStdinCharacter, isFunctionDefinition, isFunctionDefinitionName, isOption, isString, isVariable, isVariableDefinitionName } from '../utils/node-types';
import { isMatchingCompletionFlagNodeWithFishSymbol } from './complete';
import { isCompletionArgparseFlagWithCommandName } from './argparse';
import { isMatchingOption, isMatchingOptionOrOptionValue, Option } from './options';
import { isSetVariableDefinitionName } from './set';
import { extractCommands } from './nested-strings';
import { isAbbrDefinitionName, isMatchingAbbrFunction } from '../diagnostics/node-types';
import { isBindFunctionCall } from './bind';
import { isAliasDefinitionValue } from './alias';
type ReferenceContext = {
symbol: FishSymbol;
document: LspDocument;
node: SyntaxNode;
excludeEqualNode: boolean;
};
type ReferenceCheck = (ctx: ReferenceContext) => boolean;
// Early exit conditions - things we can immediately rule out
const shouldSkipNode: ReferenceCheck = ({ symbol, document, node, excludeEqualNode }) => {
if (excludeEqualNode && symbol.equalsNode(node)) return true;
if (excludeEqualNode && document.uri === symbol.uri) {
if (equalRanges(getRange(symbol.focusedNode), getRange(node))) {
return true;
}
}
if (excludeEqualNode && symbol.isEvent() && symbol.focusedNode.equals(node)) {
return true;
}
return false;
};
// Event-specific reference checking
const checkEventReference: ReferenceCheck = ({ symbol, node }) => {
if (symbol.isEventHook() && symbol.name === node.text && isEmittedEventDefinitionName(node)) {
return true;
}
if (symbol.isEmittedEvent() && symbol.name === node.text && !isEmittedEventDefinitionName(node)) {
return true;
}
return false;
};
// Scope validation for local symbols
const isInValidScope: ReferenceCheck = ({ symbol, document, node }) => {
if (symbol.isLocal() && !symbol.isArgparse()) {
return symbol.scopeContainsNode(node) && symbol.uri === document.uri;
}
return true;
};
// Function name matching
const matchesFunctionName: ReferenceCheck = ({ symbol, node }) => {
if (symbol.isFunction()) {
if (isArgumentThatCanContainCommandCalls(node)) return true;
if (symbol.name !== node.text && !isString(node)) {
return false;
}
}
return true;
};
// Complete command reference checking
const checkCompleteCommandReference: ReferenceCheck = ({ symbol, node }) => {
const parentNode = node.parent ? findParentCommand(node) : null;
if (parentNode && isCommandWithName(parentNode, 'complete')) {
return isMatchingCompletionFlagNodeWithFishSymbol(symbol, node);
}
return false;
};
// Argparse-specific reference checking
const checkArgparseReference: ReferenceCheck = ({ symbol, node }) => {
if (!symbol.isArgparse()) return false;
const parentName = symbol.parent?.name
|| symbol.scopeNode.firstNamedChild?.text
|| symbol.scopeNode.text;
// Check completion argparse flags
if (isCompletionArgparseFlagWithCommandName(node, parentName, symbol.argparseFlagName)) {
return true;
}
// Check command options
if (isOption(node) && node.parent && isCommandWithName(node.parent, parentName)) {
return isMatchingOptionOrOptionValue(node, Option.fromRaw(symbol.argparseFlag));
}
// Check variable references
if (symbol.name === node.text && symbol.parent?.scopeContainsNode(node)) {
return true;
}
const parentFunction = findParentFunction(node);
const parentNode = node.parent ? findParentCommand(node) : null;
// Variable definition checks
if (isVariable(node) || isVariableDefinitionName(node) || isSetVariableDefinitionName(node, false)) {
return symbol.name === node.text && symbol.scopeContainsNode(node);
}
// Command checks
if (parentNode && isCommandWithName(parentNode, 'set', 'read', 'for', 'export', 'argparse')) {
return !!(
symbol.name === node.text
&& symbol.scopeContainsNode(node)
&& parentFunction?.equals(symbol.scopeNode)
);
}
return false;
};
// Function-specific reference checking
const checkFunctionReference: ReferenceCheck = ({ symbol, node }) => {
if (!symbol.isFunction()) return false;
const parentNode = node.parent ? findParentCommand(node) : null;
const prevNode = node.previousNamedSibling;
// Direct command calls
if (isCommand(node) && node.text === symbol.name) return true;
// Function definitions (global functions only)
if (isFunctionDefinitionName(node) && symbol.isGlobal()) {
return symbol.equalsNode(node);
}
if (
parentNode
&& isCommandWithName(parentNode, symbol.name)
&& parentNode.firstNamedChild?.equals(node)
) {
return true;
}
// Command with name
if (isCommandWithName(node, symbol.name)) return true;
// function calls that are strings
if (isArgumentThatCanContainCommandCalls(node)) {
if (isString(node) || isOption(node)) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
return node.text === symbol.name;
}
// Type/functions commands
if (parentNode && isCommandWithName(parentNode, 'type', 'functions')) {
const firstChild = parentNode.namedChildren.find(n => !isOption(n));
return firstChild?.text === symbol.name;
}
// Wrapped functions
if (prevNode && isMatchingOption(prevNode, Option.create('-w', '--wraps')) ||
node.parent && isFunctionDefinition(node.parent) &&
isMatchingOptionOrOptionValue(node, Option.create('-w', '--wraps'))) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
// Abbreviation functions
if (parentNode && isCommandWithName(parentNode, 'abbr')) {
if (prevNode && isMatchingAbbrFunction(node)) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
const namedChild = getChildNodes(parentNode).find(n => isAbbrDefinitionName(n));
if (namedChild &&
Locations.Range.isAfter(getRange(namedChild), symbol.selectionRange) &&
!isOption(node) && node.text === symbol.name) {
return true;
}
}
// Bind commands
if (parentNode && isCommandWithName(parentNode, 'bind')) {
if (isOption(node)) return false;
if (isBindFunctionCall(node)) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
if (isString(node) && extractCommands(node).some(cmd => cmd === symbol.name)) {
return true;
}
const cmd = parentNode.childrenForFieldName('argument').slice(1)
.filter(n => !isOption(n) && !isEndStdinCharacter(n))
.find(n => n.equals(node) && n.text === symbol.name);
if (cmd) return true;
}
// Alias commands
if (parentNode && isCommandWithName(parentNode, 'alias')) {
if (isAliasDefinitionValue(node)) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
}
if (parentNode && isCommandWithName(parentNode, 'argparse')) {
if (isOption(node) || isString(node)) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
}
// Export/set/read/for/argparse commands
if (parentNode && isCommandWithName(parentNode, 'export', 'set', 'read', 'for', 'argparse')) {
if (isOption(node) || isString(node)) {
return extractCommands(node).some(cmd => cmd === symbol.name);
}
if (isVariableDefinitionName(node)) return false;
return symbol.name === node.text;
}
return symbol.name === node.text && symbol.scopeContainsNode(node);
};
// Variable-specific reference checking
const checkVariableReference: ReferenceCheck = ({ symbol, node }) => {
if (!symbol.isVariable() || node.text !== symbol.name) return false;
// Check if the node is a variaable definition with the same name
if (isVariable(node) || isVariableDefinitionName(node)) return true;
const parentNode = node.parent ? findParentCommand(node) : null;
// skip the edge case where a function could share a variables name
// NOTE: `set FOO ...` is a variable definition
// • `$FOO` will still be counted as a reference
// • `FOO` will not be counted as a references (`FOO` could be a function)
if (parentNode && isCommandWithName(parentNode, symbol.name)) {
return false;
}
if (parentNode && isCommandWithName(parentNode, 'export', 'set', 'read', 'for', 'argparse')) {
if (isOption(node)) return false;
if (isVariableDefinitionName(node)) return symbol.name === node.text;
}
return symbol.name === node.text && symbol.scopeContainsNode(node);
};
// Main reference checker that composes all the checks
const referenceCheckers: ReferenceCheck[] = [
checkEventReference,
checkArgparseReference,
checkFunctionReference,
checkVariableReference,
];
// Main function - refactored to be functional and composable
export const isSymbolReference = (
symbol: FishSymbol,
document: LspDocument,
node: SyntaxNode,
excludeEqualNode = false,
): boolean => {
const ctx: ReferenceContext = { symbol, document, node, excludeEqualNode };
// Early exits
if (shouldSkipNode(ctx)) return false;
// Check event references first (they have special handling)
if (symbol.isEvent()) {
return checkEventReference(ctx);
}
// Validate scope for local symbols
if (!isInValidScope(ctx)) return false;
// Validate function name matching
if (symbol.isFunction() && !matchesFunctionName(ctx)) return false;
// Check complete command references
const parentNode = node.parent ? findParentCommand(node) : null;
if (parentNode && isCommandWithName(parentNode, 'complete') && !isVariable(node)) {
return checkCompleteCommandReference(ctx);
}
// Run through all specific type checkers
for (const checker of referenceCheckers) {
if (checker(ctx)) return true;
}
return false;
};
================================================
FILE: src/parsing/set.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isOption, isCommandWithName, isTopLevelDefinition, findParentCommand, isConditionalCommand, hasParentFunction, findParentWithFallback, isFunctionDefinition, isScope } from '../utils/node-types';
import { Option, findOptions, findOptionsSet, isMatchingOption } from './options';
import { LspDocument } from '../document';
import { FishSymbol, ModifierScopeTag, SetModifierToScopeTag } from './symbol';
import { DefinitionScope, ScopeTag } from '../utils/definition-scope';
export const SetOptions = [
Option.create('-U', '--universal'),
Option.create('-g', '--global'),
Option.create('-f', '--function'),
Option.create('-l', '--local'),
Option.create('-x', '--export'),
Option.create('-u', '--unexport'),
Option.long('--path'),
Option.long('--unpath'),
Option.create('-a', '--append'),
Option.create('-p', '--prepend'),
Option.create('-e', '--erase'),
Option.create('-q', '--query'),
Option.create('-n', '--names'),
Option.create('-S', '--show'),
Option.long('--no-event'),
Option.create('-L', '--long'),
Option.create('-h', '--help'),
];
// const setModifiers = SetOptions.filter(option => option.equalsRawLongOption('--universal', '--global', '--function', '--local'));
export const SetModifiers = [
Option.create('-U', '--universal'),
Option.create('-g', '--global'),
Option.create('-f', '--function'),
Option.create('-l', '--local'),
];
export function isSetDefinition(node: SyntaxNode) {
return isCommandWithName(node, 'set') && !node.children.some(child => isMatchingOption(child, Option.create('-q', '--query'), Option.create('-n', '--names'), Option.create('-S', '--show'), Option.create('-e', '--erase')));
}
export function isSetQueryDefinition(node: SyntaxNode) {
return isCommandWithName(node, 'set') && node.children.some(child => isMatchingOption(child, Option.create('-q', '--query')));
}
/**
* checks if a node is the variable name of a set command
* set -g -x foo '...'
* ^-- cursor is here
*/
export function isSetVariableDefinitionName(node: SyntaxNode, excludeQuery = true) {
if (!node.parent || !isSetDefinition(node.parent)) return false;
if (excludeQuery && isSetQueryDefinition(node.parent)) return false;
const searchNodes = findSetChildren(node.parent);
const definitionNode = searchNodes.find(n => !isOption(n));
return !!definitionNode && definitionNode.equals(node);
}
function getFallbackModifierScope(document: LspDocument, node: SyntaxNode) {
const autoloadType = document.getAutoloadType();
switch (autoloadType) {
case 'conf.d':
case 'config':
case 'functions':
return isTopLevelDefinition(node) ? 'global' : hasParentFunction(node) ? 'function' : 'inherit';
case 'completions':
return isTopLevelDefinition(node) ? 'local' : hasParentFunction(node) ? 'function' : 'local';
case '':
return 'local';
default:
return 'inherit';
}
}
export function findSetChildren(node: SyntaxNode) {
const children = node.childrenForFieldName('argument');
const firstNonOption = children.findIndex(child => !isOption(child));
return children.slice(0, firstNonOption + 1);
}
export function setModifierDetailDescriptor(node: SyntaxNode) {
let children = node.childrenForFieldName('argument');
if (isSetDefinition(node)) children = findSetChildren(node);
const options = findOptions(children, SetModifiers);
const exportedOption = options.found.find(o => o.option.equalsRawOption('-x', '--export') || o.option.equalsRawOption('-u', '--unexport'));
const exportedStr = exportedOption ? exportedOption.option.isOption('-x', '--export') ? 'exported' : 'unexported' : '';
const modifier = options.found.find(o => o.option.equalsRawOption('-U', '-g', '-f', '-l'));
if (modifier) {
switch (true) {
case modifier.option.isOption('-U', '--universal'):
return ['universally scoped', exportedStr].filter(Boolean).join('; ');
case modifier.option.isOption('-g', '--global'):
return ['globally scoped', exportedStr].filter(Boolean).join('; ');
case modifier.option.isOption('-f', '--function'):
return ['function scoped', exportedStr].filter(Boolean).join('; ');
case modifier.option.isOption('-l', '--local'):
return ['locally scoped', exportedStr].filter(Boolean).join('; ');
default:
return ['', exportedStr].filter(Boolean).join('; ');
}
}
return ['', exportedStr].filter(Boolean).join('; ');
}
function findParentScopeNode(commandNode: SyntaxNode, modifier: ModifierScopeTag): SyntaxNode {
switch (modifier) {
case 'universal':
case 'global':
case 'function':
return findParentWithFallback(commandNode, (n) => isFunctionDefinition(n));
case 'inherit':
case 'local':
default:
return findParentWithFallback(commandNode, (n) => isScope(n));
}
}
export function processSetCommand(document: LspDocument, node: SyntaxNode, children: FishSymbol[] = []) {
/** skip `set -q/--query` && `set -e/--erase` */
if (!isSetDefinition(node)) return [];
// create the searchNodes, which are the nodes after the command name, but before the variable name
const searchNodes = findSetChildren(node);
// find the definition node, which should be the last node of the searchNodes
const definitionNode = searchNodes.find(n => !isOption(n));
const skipText: string[] = ['-', '$', '('];
if (
!definitionNode
|| definitionNode.type === 'concatenation' // skip `set -e FOO[1]`
|| skipText.some(t => definitionNode.text.startsWith(t)) // skip `set $FOO`, `set (FOO)`, `set -`
) return [];
const modifierOption = findOptionsSet(searchNodes, SetModifiers).pop();
let modifier = 'local' as ScopeTag;
if (modifierOption) {
modifier = SetModifierToScopeTag(modifierOption.option) as ScopeTag;
} else {
modifier = getFallbackModifierScope(document, node) as ScopeTag;
}
const options = findOptionsSet(searchNodes, SetOptions).map(o => o.option);
const scopeNode = findParentScopeNode(node, modifier);
// fix conditional_command scoping to use the parent command
// of the conditional_execution statement, so that
// we can reference the variable in the parent scope
let parentNode = findParentCommand(node.parent || node) || node.parent || node;
if (parentNode && isConditionalCommand(parentNode)) {
while (parentNode && isConditionalCommand(parentNode)) {
if (parentNode.type === 'function_definition') break;
if (!parentNode.parent) break;
parentNode = parentNode.parent;
}
}
return [
FishSymbol.create(
definitionNode.text.toString(),
node,
definitionNode,
'SET',
document,
document.uri,
node.text.toString(),
DefinitionScope.create(scopeNode, modifier),
options,
children,
),
];
}
================================================
FILE: src/parsing/source.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { findParentFunction, isCommandWithName, isFunctionDefinition, isProgram, isTopLevelDefinition } from '../utils/node-types';
import { SyncFileHelper } from '../utils/file-operations';
import { Range } from 'vscode-languageserver';
import { LspDocument } from '../document';
import { Analyzer } from '../analyze';
import { getParentNodesGen, getRange, precedesRange } from '../utils/tree-sitter';
import { DefinitionScope } from '../utils/definition-scope';
import { FishSymbol } from './symbol';
import { uriToPath } from '../utils/translation';
import path, { dirname, isAbsolute } from 'path';
import { workspaceManager } from '../utils/workspace-manager';
import { findFirstExistingFile, isExistingFile } from '../utils/path-resolution';
// TODO think of better naming conventions for these functions
export function isSourceCommandName(node: SyntaxNode) {
return isCommandWithName(node, 'source') || isCommandWithName(node, '.');
}
export function isSourceCommandWithArgument(node: SyntaxNode) {
return isSourceCommandName(node) && node.childCount > 1 && node.child(1)?.text !== '-';
}
export function isSourceCommandArgumentName(node: SyntaxNode) {
if (node.parent && isSourceCommandWithArgument(node.parent)) {
return node.parent?.child(1)?.equals(node) && node.isNamed && node.text !== '-';
}
return false;
}
export function isSourcedFilename(node: SyntaxNode) {
if (node.parent && isSourceCommandName(node.parent)) {
return node.parent?.child(1)?.equals(node) && node.isNamed && node.text !== '-';
}
return false;
}
export function isExistingSourceFilenameNode(node: SyntaxNode, baseDir?: string) {
if (!isSourcedFilename(node)) return false;
const resolvedPath = resolveSourcePath(node.text, baseDir);
return resolvedPath && isExistingFile(resolvedPath);
}
export function getExpandedSourcedFilenameNode(node: SyntaxNode, baseDir?: string) {
if (!isSourcedFilename(node)) return undefined;
const resolvedPath = resolveSourcePath(node.text, baseDir);
if (resolvedPath && isExistingFile(resolvedPath)) {
return SyncFileHelper.expandEnvVars(resolvedPath);
}
return undefined;
}
/**
* Resolves a source path that might be relative, relative to the base directory
* @param sourcePath The path from the source command (e.g., "./scripts/file.fish", "/abs/path.fish")
* @param baseDir The directory to resolve relative paths against (usually the directory containing the sourcing script)
* @returns The resolved absolute path, or the original path if it was already absolute
*/
function resolveSourcePath(sourcePath: string, baseDir?: string): string {
// Expand environment variables first
const expandedPath = SyncFileHelper.expandEnvVars(sourcePath);
// If it's already an absolute path, return as-is
if (isAbsolute(expandedPath)) {
return expandedPath;
}
// Try to find the file in multiple possible locations
const foundPath = findFirstExistingFile(
path.join(baseDir || workspaceManager.current?.path || process.cwd(), expandedPath),
path.resolve(process.cwd(), expandedPath),
path.resolve(process.env.PWD || '', expandedPath),
path.resolve(workspaceManager.current?.path || '', expandedPath),
);
// Return the found path or the expanded path as fallback
return foundPath ?? expandedPath;
}
export interface SourceResource {
from: LspDocument;
to: LspDocument;
range: Range;
node: SyntaxNode;
definitionScope: DefinitionScope;
// children: FishSymbol[];
sources: SourceResource[];
}
export class SourceResource {
constructor(
public from: LspDocument,
public to: LspDocument,
public range: Range,
public node: SyntaxNode,
public definitionScope: DefinitionScope,
// public children: FishSymbol[],
public sources: SourceResource[],
) { }
static create(
from: LspDocument,
to: LspDocument,
range: Range,
node: SyntaxNode,
sources: SourceResource[],
) {
let scopeParent: SyntaxNode | null = node.parent;
for (const parent of getParentNodesGen(node)) {
if (isFunctionDefinition(parent) || isProgram(parent)) {
scopeParent = parent;
break;
}
}
const definitionScope = DefinitionScope.create(scopeParent!, 'local');
return new SourceResource(from, to, range, node, definitionScope, sources);
}
scopeReachableFromNode(node: SyntaxNode) {
const parent = findParentFunction(node);
const isTopLevel = isTopLevelDefinition(this.node);
if (parent && !isTopLevel) return this.definitionScope.containsNode(node);
return this.definitionScope.containsNode(node) && node.startIndex >= this.definitionScope.scopeNode.startIndex;
}
}
export function createSourceResources(analyzer: Analyzer, from: LspDocument): SourceResource[] {
const result: SourceResource[] = [];
// Get the directory containing the current document for resolving relative paths
const fromPath = uriToPath(from.uri);
const baseDir = dirname(fromPath);
const nodes = analyzer.getNodes(from.uri).filter(n => {
return isSourceCommandArgumentName(n) && !!isExistingSourceFilenameNode(n, baseDir);
});
if (nodes.length === 0) return result;
for (const node of nodes) {
const sourcedFile = getExpandedSourcedFilenameNode(node, baseDir);
if (!sourcedFile) continue;
const to = analyzer.getDocumentFromPath(sourcedFile) ||
SyncFileHelper.toLspDocument(sourcedFile);
const range = getRange(node);
analyzer.analyze(to);
const sources = createSourceResources(analyzer, to);
result.push(SourceResource.create(from, to, range, node, sources));
}
return result;
}
export function reachableSources(resources: SourceResource[], uniqueUris: Set = new Set()): SourceResource[] {
const result: SourceResource[] = [];
const sourceShouldInclude = (
child: SourceResource,
parent: SourceResource,
) => {
return child.definitionScope.containsNode(parent.node)
&& precedesRange(parent.range, child.range)
&& !uniqueUris.has(child.to.uri);
};
for (const resource of resources) {
const children = reachableSources(resource.sources);
if (!uniqueUris.has(resource.to.uri)) {
uniqueUris.add(resource.to.uri);
result.push(resource);
}
for (const child of children) {
if (sourceShouldInclude(child, resource)) {
uniqueUris.add(child.to.uri);
result.push(child);
}
}
}
return result;
}
export function symbolsFromResource(analyzer: Analyzer, resources: SourceResource, uniqueNames: Set = new Set()): FishSymbol[] {
const result: FishSymbol[] = [];
const symbols = analyzer.getFlatDocumentSymbols(resources.to.uri);
for (const symbol of symbols) {
if (uniqueNames.has(symbol.name)) continue;
if (symbol.isGlobal() || symbol.isRootLevel()) {
result.push(symbol);
}
}
return result;
}
================================================
FILE: src/parsing/string.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
/**
* Resolves a single fish shell escape sequence token to its character value.
*
* In unquoted fish strings `\X` where X is not a recognised special character
* resolves to just `X`. Recognised specials follow the standard C/fish
* convention (`\n`, `\t`, `\e`, `\u`, …).
*
* @param seq - raw escape-sequence text, e.g. `\n`, `\m`, `\uXXXX`
* @returns the resolved character(s)
*/
function unescapeSequence(seq: string): string {
if (!seq.startsWith('\\') || seq.length < 2) return seq;
const char = seq[1]!;
switch (char) {
case 'a': return '\x07'; // bell
case 'b': return '\x08'; // backspace
case 'e': return '\x1B'; // escape
case 'f': return '\x0C'; // form feed
case 'n': return '\n'; // newline
case 'r': return '\r'; // carriage return
case 't': return '\t'; // tab
case 'v': return '\x0B'; // vertical tab
case '\\': return '\\';
case ' ': return ' ';
case 'u': {
const cp = parseInt(seq.slice(2), 16);
return isNaN(cp) ? seq : String.fromCodePoint(cp);
}
case 'U': {
const cp = parseInt(seq.slice(2), 16);
return isNaN(cp) ? seq : String.fromCodePoint(cp);
}
case 'x': {
const cp = parseInt(seq.slice(2), 16);
return isNaN(cp) ? seq : String.fromCodePoint(cp);
}
case 'o': {
const cp = parseInt(seq.slice(2), 8);
return isNaN(cp) ? seq : String.fromCodePoint(cp);
}
case 'c': {
const ctrl = seq[2];
return ctrl ? String.fromCharCode(ctrl.toUpperCase().charCodeAt(0) - 64) : seq;
}
default:
// Any other \X → X (backslash is simply dropped)
return char;
}
}
/**
* Utilities for extracting the bare string value from any fish shell string
* surface form — quoted, escaped, or plain.
*
* Fish strings can appear in multiple forms that all denote the same value:
*
* `mas` → `word` node / plain text → `"mas"`
* `'mas'` → `single_quote_string` node → `"mas"`
* `"mas"` → `double_quote_string` node → `"mas"`
* `\mas` → `concatenation` node → `"mas"`
* `\ma\s` → `concatenation` node → `"mas"`
* `ma\s` → `concatenation` node → `"mas"`
*
* @see https://github.com/ndonfris/fish-lsp/issues/140
*/
export namespace FishString {
/**
* Extracts the bare string value from a fish shell SyntaxNode.
* Strips surrounding quotes and resolves escape sequences.
*/
export function fromNode(node: SyntaxNode): string {
switch (node.type) {
case 'single_quote_string':
case 'double_quote_string':
return node.text.slice(1, -1);
case 'concatenation':
return node.children
.map(child =>
child.type === 'escape_sequence'
? unescapeSequence(child.text)
: child.text)
.join('');
default:
// Covers plain `word` nodes and any future node types.
return node.text;
}
}
/**
* Extracts the bare string value from a raw fish shell text string.
* Strips surrounding quotes and resolves escape sequences.
* Use `fromNode` instead when a SyntaxNode is available.
*/
export function fromText(text: string): string {
if (text.length >= 2) {
if (text.startsWith("'") && text.endsWith("'")) return text.slice(1, -1);
if (text.startsWith('"') && text.endsWith('"')) return text.slice(1, -1);
}
// Resolve escape sequences in unquoted / concatenation-style text.
// Alternation is ordered longest-first so \uXXXX is matched before the
// catch-all single-character branch.
return text.replace(
/\\(u[0-9a-fA-F]{1,4}|U[0-9a-fA-F]{1,8}|x[0-9a-fA-F]{1,2}|o[0-7]{1,3}|c[a-zA-Z]|[\s\S])/g,
(seq) => unescapeSequence(seq),
);
}
/**
* Convenience overload — dispatches to `fromNode` or `fromText` based on
* the type of `input`.
*/
export function parse(input: SyntaxNode | string): string {
return typeof input === 'string' ? fromText(input) : fromNode(input);
}
}
================================================
FILE: src/parsing/symbol-converters.ts
================================================
import { DocumentSymbol, WorkspaceSymbol, Location, FoldingRange, FoldingRangeKind, MarkupContent, MarkupKind, Hover, DocumentUri } from 'vscode-languageserver';
import { FishSymbol } from './symbol';
// === INTERNAL HELPER FUNCTIONS (not exported) ===
export namespace SymbolConverters {
// Internal helper to check if symbol should be included as document symbol
const shouldIncludeAsDocumentSymbol = (symbol: FishSymbol): boolean => {
switch (true) {
case symbol.fishKind === 'FUNCTION_EVENT':
return false; // Emitted events are not included as document symbols
default:
return true;
}
};
// Internal helper to process children for document symbols
const processDocumentSymbolChildren = (symbol: FishSymbol): DocumentSymbol[] => {
const visitedChildren: DocumentSymbol[] = [];
for (const child of symbol.children) {
if (!shouldIncludeAsDocumentSymbol(child)) continue;
const newChild = symbolToDocumentSymbol(child);
if (newChild) {
visitedChildren.push(newChild);
}
}
return visitedChildren;
};
// Internal helper to create markup content
const createMarkupContent = (symbol: FishSymbol): MarkupContent => {
return {
kind: MarkupKind.Markdown,
value: symbol.detail,
};
};
// === PUBLIC API FUNCTIONS (exported) ===
// Convert symbol to WorkspaceSymbol
export const symbolToWorkspaceSymbol = (symbol: FishSymbol): WorkspaceSymbol => {
return WorkspaceSymbol.create(
symbol.name,
symbol.kind,
symbol.uri,
symbol.selectionRange,
);
};
// Convert symbol to DocumentSymbol
export const symbolToDocumentSymbol = (symbol: FishSymbol): DocumentSymbol | undefined => {
if (!shouldIncludeAsDocumentSymbol(symbol)) {
return undefined;
}
const children = processDocumentSymbolChildren(symbol);
return DocumentSymbol.create(
symbol.name,
symbol.detail,
symbol.kind,
symbol.range,
symbol.selectionRange,
children,
);
};
// Convert symbol to Location
export const symbolToLocation = (symbol: FishSymbol): Location => {
return Location.create(
symbol.uri,
symbol.selectionRange,
);
};
// Convert symbol to Position
export const symbolToPosition = (symbol: FishSymbol): { line: number; character: number; } => {
return {
line: symbol.selectionRange.start.line,
character: symbol.selectionRange.start.character,
};
};
// Convert symbol to FoldingRange
export const symbolToFoldingRange = (symbol: FishSymbol): FoldingRange => {
return {
startLine: symbol.range.start.line,
endLine: symbol.range.end.line,
startCharacter: symbol.range.start.character,
endCharacter: symbol.range.end.character,
collapsedText: symbol.name,
kind: FoldingRangeKind.Region,
};
};
// Convert symbol to MarkupContent
export const symbolToMarkupContent = (symbol: FishSymbol): MarkupContent => {
return createMarkupContent(symbol);
};
// Convert symbol to Hover (with optional current URI for range inclusion)
export const symbolToHover = (symbol: FishSymbol, currentUri: DocumentUri = ''): Hover => {
return {
contents: createMarkupContent(symbol),
range: currentUri === symbol.uri ? symbol.selectionRange : undefined,
};
};
export const copySymbol = (symbol: FishSymbol): FishSymbol => {
return new FishSymbol({
name: symbol.name,
detail: symbol.detail,
document: symbol.document,
uri: symbol.uri,
fishKind: symbol.fishKind,
node: symbol.node,
focusedNode: symbol.focusedNode,
scope: symbol.scope,
range: symbol.range,
selectionRange: symbol.selectionRange,
children: symbol.children.map(copySymbol), // NOT Recursive but probably should be
});
};
export const symbolToString = (symbol: FishSymbol): string => {
return JSON.stringify({
name: symbol.name,
kind: symbol.kind,
uri: symbol.uri,
scope: symbol.scope.scopeTag,
detail: symbol.detail,
range: symbol.range,
selectionRange: symbol.selectionRange,
aliasedNames: symbol.aliasedNames,
children: symbol.children.map(child => child.name),
}, null, 2);
};
}
================================================
FILE: src/parsing/symbol-detail.ts
================================================
import { SymbolKind } from 'vscode-languageserver';
import { FishSymbol, fishSymbolKindToSymbolKind } from './symbol';
import { md } from '../utils/markdown-builder';
import { findOptions } from './options';
import { findFunctionDefinitionChildren, FunctionOptions } from './function';
import { uriToReadablePath, uriToPath } from '../utils/translation';
import { FishString } from './string';
import { PrebuiltDocumentationMap } from '../utils/snippets';
import { setModifierDetailDescriptor, SetModifiers } from './set';
import { SyntaxNode } from 'web-tree-sitter';
import { FishAlias } from './alias';
import { env } from '../utils/env-manager';
import { logger } from '../logger';
// IF YOU ARE READING THIS FILE, PLEASE FEEL FREE TO REFACTOR IT (sorry my brain is fried)
/**
* Since a SyntaxNode's text could equal something like:
* ```fish
* # assume we are indented one level, (if_statement wont have leading spaces)
* if true
* echo "Hello, world!"
* end
* ```
* We want to remove a single indentation level from the text, after the first line.
* @param node The SyntaxNode to unindent
* @returns The unindented text of the SyntaxNode (the last line's indentation amount will be how much is removed from the rest of the lines)
*/
export function unindentNestedSyntaxNode(node: SyntaxNode) {
const lines = node.text.split('\n');
if (lines.length > 1) {
const lastLine = node.lastChild?.startPosition.column || 0;
return lines
.map(line => line.replace(' '.repeat(lastLine), ''))
.join('\n')
.trimEnd();
}
return node.text;
}
function getSymbolKind(symbol: FishSymbol) {
const kind = fishSymbolKindToSymbolKind[symbol.fishKind];
switch (kind) {
case SymbolKind.Variable:
return 'variable';
case SymbolKind.Function:
return 'function';
default:
return '';
}
}
/**
* Checks if a file path is within any autoloaded fish directories
*
* @param uriOrPath The URI or filesystem path to check
* @param type Optional specific autoload type to check for (e.g., 'functions', 'completions')
* @returns True if the path is within an autoloaded directory, false otherwise
*/
export function isAutoloadedPath(uriOrPath: string, type?: string): boolean {
// Convert URI to path if necessary
const path = uriOrPath.startsWith('file://') ? uriToPath(uriOrPath) : uriOrPath;
// Get all autoloaded variables from the environment
const autoloadedKeys = env.getAutoloadedKeys();
for (const key of autoloadedKeys) {
// Skip if we're looking for a specific type and this key doesn't match
if (type && !key.toLowerCase().includes(type.toLowerCase())) {
continue;
}
const values = env.getAsArray(key);
for (const value of values) {
if (path.startsWith(value)) {
return true;
}
}
}
return false;
}
/**
* Gets the autoload type of a path if it is autoloaded
*
* @param uriOrPath The URI or filesystem path to check
* @returns The identified autoload type ('functions', 'completions', 'conf.d', etc.) or empty string if not autoloaded
*/
export function getAutoloadType(uriOrPath: string): string {
// Convert URI to path if necessary
const path = uriOrPath.startsWith('file://') ? uriToPath(uriOrPath) : uriOrPath;
// Common autoload types to check for
const autoloadTypes = ['functions', 'completions', 'conf.d', 'config'];
// Check path for these common types
for (const type of autoloadTypes) {
if (path.includes(`/fish/${type}`)) {
return type;
}
// Special case for config.fish
if (type === 'config' && path.endsWith('config.fish')) {
return 'config';
}
}
// If no specific type was found but the path is autoloaded, return a generic indicator
if (isAutoloadedPath(path)) {
return 'autoloaded';
}
return '';
}
function buildFunctionDetail(symbol: FishSymbol) {
const { name, node, fishKind } = symbol;
if (fishKind === 'ALIAS') {
return FishAlias.buildDetail(node) as string;
}
const options = findOptions(findFunctionDefinitionChildren(node), FunctionOptions);
const descriptionOption = options.found.find(option => option.option.isOption('-d', '---description'));
const description = [`(${md.bold('function')}) ${md.inlineCode(name)}`];
if (descriptionOption && descriptionOption.value) {
description.push(
FishString.fromNode(descriptionOption.value),
);
}
description.push(md.separator());
const scope: string[] = [];
if (isAutoloadedPath(symbol.uri)) {
scope.push('autoloaded');
}
if (symbol.isGlobal()) {
scope.push('globally scoped');
}
if (scope.length > 0) {
description.push(scope.join(', '));
}
description.push(`located in file: ${md.inlineCode(uriToReadablePath(symbol.uri))}`);
description.push(md.separator());
const prebuilt = PrebuiltDocumentationMap.getByType('command').find(c => c.name === name);
if (prebuilt) {
description.push(prebuilt.description);
description.push(md.separator());
}
description.push(md.codeBlock('fish', unindentNestedSyntaxNode(node)));
const argumentNamesOption = options.found.filter(option => option.option.isOption('-a', '--argument-names'));
if (argumentNamesOption && argumentNamesOption.length) {
const functionCall = [name];
for (const arg of argumentNamesOption) {
functionCall.push(arg.value.text);
}
description.push(md.separator());
description.push(md.codeBlock('fish', functionCall.join(' ')));
}
return description.join(md.newline());
}
function isVariableArgumentNamed(node: SyntaxNode, name: string) {
if (node.type !== 'function_definition') return '';
const children = findFunctionDefinitionChildren(node);
if (findOptions(children, FunctionOptions).found
.filter(flag => flag.option.isOption('-a', '--argument-names'))
.some(flag => flag.value.text === name)) {
return true;
}
return false;
}
function getArgumentNamesIndexString(node: SyntaxNode, name: string) {
if (node?.type && node?.type !== 'function_definition') return '';
const children = findFunctionDefinitionChildren(node);
// const resultFlags: string[] = [];
const index = findOptions(children, FunctionOptions).found
.filter(flag => flag.option.isOption('-a', '--argument-names'))
.findIndex((flag) => flag.value.text === name);
const argvStr = '$argv[' + (index + 1) + ']';
return `${md.italic('named argument')}: ${md.inlineCode(argvStr)}`;
}
function buildVariableDetail(symbol: FishSymbol) {
const { name, node, uri, fishKind } = symbol;
if (!node) return '';
const description = [`(${md.bold('variable')}) ${md.inlineCode(name)}`];
// add short info about variable
description.push(md.separator());
if (fishKind === 'SET' || fishKind === 'READ') {
const setModifiers = SetModifiers.filter(option => option.equalsRawLongOption('--universal', '--global', '--function', '--local', '--export', '--unexport'));
const options = findOptions(node.childrenForFieldName('argument'), setModifiers);
const modifier = options.found.find(o => o.option.equalsRawOption('-U', '-g', '-f', '-l', '-x', '-u'));
if (modifier) {
description.push(setModifierDetailDescriptor(node));
}
} else if (fishKind === 'ARGPARSE') {
description.push('locally scoped');
} else if (node && isVariableArgumentNamed(node, name)) {
try {
const result = getArgumentNamesIndexString(node, name);
description.push(result);
} catch (e) {
logger.error('ERROR: building variable detail', e);
}
}
// add location
description.push(`located in file: ${md.inlineCode(uriToReadablePath(uri))}`);
// add prebuilt documentation if available
const prebuilt = PrebuiltDocumentationMap.getByType('variable').find(c => c.name === name);
if (prebuilt) {
description.push(md.separator());
description.push(prebuilt.description);
}
description.push(md.separator());
// add code block of entire region
description.push(md.codeBlock('fish', unindentNestedSyntaxNode(node)));
// add trailing `cmd --arg`, `cmd $argv`, `func $argv` examples
const scopeCommand = symbol.scope.scopeNode?.type === 'program'
? `${uriToReadablePath(uri)}`
: `${symbol.scope.scopeNode?.firstNamedChild?.text}` || `${symbol.node}`;
if (fishKind === 'ARGPARSE') {
const argumentNamesOption = symbol.name.slice('_flag_'.length).replace(/_/g, '-');
if (argumentNamesOption.length > 1) {
description.push(md.separator());
description.push(md.codeBlock('fish', `${scopeCommand} --${argumentNamesOption}`));
} else if (argumentNamesOption.length === 1) {
description.push(md.separator());
description.push(md.codeBlock('fish', `${scopeCommand} -${argumentNamesOption}`));
}
} else if (name === 'argv') {
description.push(md.separator());
description.push(md.codeBlock('fish', `${scopeCommand} $argv`));
} else if (node.type === 'function_definition') {
const children = findFunctionDefinitionChildren(node);
const resultFlags: string[] = [];
findOptions(children, FunctionOptions).found
.filter(flag => flag.option.isOption('-a', '--argument-names'))
.forEach((flag, idx) => {
if (flag.value.text === name) resultFlags.push(flag.value.text);
else resultFlags.push(`\$argv[${idx + 1}]`);
});
if (resultFlags.length) {
description.push(md.separator());
description.push(md.codeBlock('fish', `${scopeCommand} ${resultFlags.join(' ')}`));
}
}
return description.join(md.newline());
}
export function createDetail(symbol: FishSymbol) {
if (symbol.fishKind === 'EXPORT') return symbol.detail.toString();
const symbolKind = getSymbolKind(symbol);
if (symbolKind === '') return symbol.detail;
if (symbolKind === 'function') {
return buildFunctionDetail(symbol);
}
if (symbolKind === 'variable') {
return buildVariableDetail(symbol);
}
return symbol.detail.toString();
}
================================================
FILE: src/parsing/symbol-kinds.ts
================================================
import { SymbolKind, Range } from 'vscode-languageserver';
import { FishSymbol } from './symbol';
import { Option } from './options';
/**
* ALL possible `FishSymbol.fishKind` values
*/
export type FishSymbolKind = 'ARGPARSE' | 'FUNCTION' | 'ALIAS' | 'COMPLETE' | 'SET' | 'READ' | 'FOR' | 'VARIABLE' | 'FUNCTION_VARIABLE' | 'EXPORT' | 'EVENT' | 'FUNCTION_EVENT' | 'INLINE_VARIABLE';
/**
* Map/Record of all possible FishSymbolKind values, with lowercase keys to uppercase values.
* Uppercase values are used for the `FishSymbol.fishKind` property.
* Lowercase keys are used for displaying the fishKind in the UI.
*/
export const FishSymbolKindMap: Record, FishSymbolKind> = {
['argparse']: 'ARGPARSE',
['function']: 'FUNCTION',
['alias']: 'ALIAS',
['complete']: 'COMPLETE',
['set']: 'SET',
['read']: 'READ',
['for']: 'FOR',
['variable']: 'VARIABLE',
['event']: 'EVENT',
['function_variable']: 'FUNCTION_VARIABLE',
['function_event']: 'FUNCTION_EVENT',
['export']: 'EXPORT',
['inline_variable']: 'INLINE_VARIABLE',
};
/**
* Maps FishSymbolKind to SymbolKind for use in the LSP.
* Each FishSymbol.fishKind is mapped to its corresponding SymbolKind.
*/
export const fishSymbolKindToSymbolKind: Record = {
['ARGPARSE']: SymbolKind.Variable,
['FUNCTION']: SymbolKind.Function,
['ALIAS']: SymbolKind.Function,
['COMPLETE']: SymbolKind.Interface,
['SET']: SymbolKind.Variable,
['READ']: SymbolKind.Variable,
['FOR']: SymbolKind.Variable,
['VARIABLE']: SymbolKind.Variable,
['FUNCTION_VARIABLE']: SymbolKind.Variable,
['EVENT']: SymbolKind.Event,
['FUNCTION_EVENT']: SymbolKind.Event,
['EXPORT']: SymbolKind.Variable,
['INLINE_VARIABLE']: SymbolKind.Variable,
} as const;
/**
* Creates an object that returns the string representation of each SymbolKind.
*/
export const createSymbolKindLookup = (): Record => {
const lookup = {} as Record;
for (const [key, value] of Object.entries(SymbolKind)) {
if (typeof value === 'number') {
lookup[value] = key;
}
}
return lookup;
};
const symbolKindToStringMap = createSymbolKindLookup();
/**
* Function to get the string representation of a SymbolKind, from its numeric value.
*/
export const getSymbolKindToString = (kind: SymbolKind): string => {
return symbolKindToStringMap[kind] || 'Unknown';
};
export namespace FishSymbolKind {
/**
* Checks if the given kind is a valid FishSymbolKind.
*/
export const is = (kind: unknown): kind is FishSymbolKind => {
if (typeof kind !== 'string') return false;
return Object.keys(FishSymbolKindMap).includes(kind.toLowerCase());
};
/**
* Converts a FishSymbolKind to its corresponding SymbolKind string.
*/
export const toSymbolKindStr = (kind: FishSymbolKind): string => {
return fishSymbolKindToSymbolKind[kind]?.toString();
};
}
export const fromFishSymbolKindToSymbolKind = (kind: FishSymbolKind) => fishSymbolKindToSymbolKind[kind];
/**
* Converts either a FishSymbol.fishKind or a SymbolKind to its string representation.
*/
export const symbolKindToString = (kind: SymbolKind | FishSymbolKind): string => {
if (FishSymbolKind.is(kind)) {
return FishSymbolKind.toSymbolKindStr(kind);
}
return getSymbolKindToString(kind);
};
/***
* Used to simplify checking FishSymbol.is()
*/
type kindGroups = 'VARIABLES' | 'FUNCTIONS' | 'EVENTS' | 'ARGPARSE' | 'OTHER';
export const FishKindGroups: Record = {
VARIABLES: ['ARGPARSE', 'SET', 'READ', 'FOR', 'VARIABLE', 'FUNCTION_VARIABLE', 'EXPORT'],
FUNCTIONS: ['FUNCTION', 'ALIAS'],
EVENTS: ['EVENT', 'FUNCTION_EVENT'],
ARGPARSE: ['ARGPARSE'],
OTHER: ['COMPLETE'],
} as const;
/**
* FishSymbolInput is a type that represents the input required to create a FishSymbol.
* These are the minimum required fields to build all of the FishSymbol properties.
*/
export type FishSymbolInput = Pick & {
name?: string;
uri?: string;
range?: Range;
selectionRange?: Range;
options?: Option[];
};
================================================
FILE: src/parsing/symbol-modifiers.ts
================================================
import { SetOptions } from './set';
import { ReadOptions } from './read';
import { ArgparseOptions } from './argparse';
import { CompleteOptions } from './complete';
import { FunctionOptions, FunctionVariableOptions } from './function';
import { FishSymbolKind } from './symbol-kinds';
import { Option } from './options';
import { SemanticTokenModifier, SemanticTokenType } from '../utils/semantics';
import { FishSymbol } from './symbol';
export const SymbolModifiers: Record = {
SET: SetOptions,
READ: ReadOptions,
FOR: [],
ARGPARSE: ArgparseOptions,
VARIABLE: [],
FUNCTION_VARIABLE: [...FunctionVariableOptions],
FUNCTION: FunctionOptions,
ALIAS: [Option.create('-g', '--global'), Option.create('-f', '--function')],
COMPLETE: CompleteOptions,
EVENT: [],
FUNCTION_EVENT: [],
EXPORT: [Option.create('-g', '--global'), Option.create('-x', '--export')],
INLINE_VARIABLE: [Option.create('-x', '--export')],
};
function getSetReadModifiers(symbol: FishSymbol): SemanticTokenModifier[] {
const options: Option[] = symbol.options || [];
const result = new Set();
result.add(symbol.scopeTag as SemanticTokenModifier);
for (const opt of options) {
if (opt.isOption('-g', '--global')) {
result.add('global');
}
if (opt.isOption('-l', '--local')) {
result.add('local');
}
if (opt.isOption('-x', '--export')) {
result.add('export');
}
if (opt.isOption('-U', '--universal')) {
result.add('universal');
}
if (opt.isOption('-f', '--function')) {
result.add('function');
}
}
if (!result.has(symbol.scopeTag)) {
result.add(symbol.scopeTag as SemanticTokenModifier);
}
if (result.size === 0) {
result.add('local');
}
return Array.from([...result]);
}
export const scopeTagToModifierMap: Record = {
global: 'global',
local: 'local',
universal: 'universal',
function: 'function',
inherit: 'inherit',
};
export function getSymbolModifiers(symbol: FishSymbol): SemanticTokenModifier[] {
// const mods: FishSemanticTokenModifier[] = ['definition'];
const mods: SemanticTokenModifier[] = [];
switch (symbol.fishKind) {
case 'SET':
case 'READ':
return [...mods, ...getSetReadModifiers(symbol)];
case 'FUNCTION':
if (
symbol.isGlobal()
&& symbol.document.isAutoloaded()
&& symbol.name === symbol.document.getAutoLoadName()
) {
mods.push('global' /*'autoloaded'*/);
} else if (symbol.isLocal() && symbol.document.isAutoloadedUri()) {
mods.push('local' /*'not-autoloaded'*/);
} else if (symbol.isLocal()) {
mods.push('local');
}
return mods;
case 'FUNCTION_VARIABLE':
if (scopeTagToModifierMap[symbol.scope.scopeTag]) {
return [...mods, scopeTagToModifierMap[symbol.scope.scopeTag]!];
}
return mods;
case 'ARGPARSE':
return [...mods, 'local'];
case 'ALIAS':
if (symbol.document.isAutoloaded() && symbol.scope.scopeTag === 'global') mods.push('global');
mods.push(/*'script'*/);
return mods;
case 'EXPORT':
return [...mods, 'global', 'export'];
case 'FOR':
return [...mods, 'local'];
case 'VARIABLE':
if (scopeTagToModifierMap[symbol.scope.scopeTag]) {
mods.push(scopeTagToModifierMap[symbol.scope.scopeTag]!);
return mods;
}
return [];
case 'EVENT':
case 'FUNCTION_EVENT':
if (symbol.scope.scopeTag) {
mods.push(scopeTagToModifierMap[symbol.scope.scopeTag] ?? 'local');
return mods;
}
return [];
case 'COMPLETE':
if (symbol.scope.scopeTag) {
mods.push(scopeTagToModifierMap[symbol.scope.scopeTag] ?? 'local');
return mods;
}
return mods;
default:
return [];
}
}
export const FishSymbolToSemanticToken: Record = {
SET: 'variable',
READ: 'variable',
FOR: 'variable',
ARGPARSE: 'variable',
VARIABLE: 'variable',
FUNCTION_VARIABLE: 'variable',
FUNCTION: 'function',
ALIAS: 'function',
COMPLETE: 'function',
EVENT: 'event',
FUNCTION_EVENT: 'event',
EXPORT: 'variable',
INLINE_VARIABLE: 'variable',
};
================================================
FILE: src/parsing/symbol.ts
================================================
import { DocumentSymbol, SymbolKind, WorkspaceSymbol, Location, FoldingRange, MarkupContent, Hover, DocumentUri, Position } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { DefinitionScope } from '../utils/definition-scope';
import { LspDocument } from '../document';
import { containsNode, getChildNodes, getRange } from '../utils/tree-sitter';
import { findSetChildren, processSetCommand } from './set';
import { processReadCommand } from './read';
import { isFunctionVariableDefinitionName, processArgvDefinition, processFunctionDefinition } from './function';
import { processForDefinition } from './for';
import { convertNodeRangeWithPrecedingFlag, processArgparseCommand } from './argparse';
import { Flag, isMatchingOption, LongFlag, Option, ShortFlag } from './options';
import { processAliasCommand } from './alias';
import { createDetail } from './symbol-detail';
import { config } from '../config';
import { flattenNested } from '../utils/flatten';
import { uriToPath } from '../utils/translation';
import { FishString } from './string';
import { isCommand, isCommandWithName, isEmptyString, isFunctionDefinitionName, isVariableDefinitionName } from '../utils/node-types';
import { SyncFileHelper } from '../utils/file-operations';
import { isExportVariableDefinitionName, processExportCommand } from './export';
import { CompletionSymbol, isCompletionCommandDefinition, isCompletionSymbol } from './complete';
import { analyzer } from '../analyze';
import { isEmittedEventDefinitionName, isGenericFunctionEventHandlerDefinitionName, processEmitEventCommandName } from './emit';
import { isSymbolReference } from './reference-comparator';
import { equalSymbolDefinitions, equalSymbols, equalSymbolScopes, fishSymbolNameEqualsNodeText, isFishSymbol, symbolContainsNode, symbolContainsPosition, symbolContainsScope, symbolEqualsLocation, symbolEqualsNode, symbolScopeContainsNode } from './equality-utils';
import { SymbolConverters } from './symbol-converters';
import { FishKindGroups, FishSymbolInput, FishSymbolKind, fishSymbolKindToSymbolKind, fromFishSymbolKindToSymbolKind } from './symbol-kinds';
import { isInlineVariableAssignment, processInlineVariables } from './inline-variable';
export const SKIPPABLE_VARIABLE_REFERENCE_NAMES = [
'argv',
'fish_trace',
];
export interface FishSymbol extends DocumentSymbol {
document: LspDocument;
uri: string;
fishKind: FishSymbolKind;
node: SyntaxNode;
focusedNode: SyntaxNode;
scope: DefinitionScope;
children: FishSymbol[];
detail: string;
options: Option[];
parent: FishSymbol | undefined;
}
export class FishSymbol {
public children: FishSymbol[] = [];
public aliasedNames: string[] = [];
public document: LspDocument;
public options: Option[] = [];
constructor(obj: FishSymbolInput) {
this.name = obj.name || obj.focusedNode.text;
this.kind = fromFishSymbolKindToSymbolKind(obj.fishKind);
this.fishKind = obj.fishKind;
this.document = obj.document;
this.uri = obj.uri || obj.document.uri.toString();
this.range = obj.range || getRange(obj.node);
this.selectionRange = obj.selectionRange || getRange(obj.focusedNode);
this.node = obj.node;
this.focusedNode = obj.focusedNode;
this.scope = obj.scope;
this.children = obj.children;
this.children.forEach(child => {
child.parent = this;
});
this.options = obj.options || [];
this.detail = obj.detail;
this.setupDetail();
}
setupDetail() {
this.detail = createDetail(this);
}
static create(
name: string,
node: SyntaxNode,
focusedNode: SyntaxNode,
fishKind: FishSymbolKind,
document: LspDocument,
uri: string = document.uri.toString(),
detail: string,
scope: DefinitionScope,
options: Option[] = [],
children: FishSymbol[] = [],
) {
return new this({
name: name || focusedNode.text,
fishKind,
document,
uri,
detail,
node,
focusedNode,
options,
scope,
children,
});
}
static fromObject(obj: FishSymbolInput) {
return new this(obj);
}
public copy(): FishSymbol {
return SymbolConverters.copySymbol(this);
}
static is(obj: unknown): obj is FishSymbol {
return isFishSymbol(obj);
}
addChildren(...children: FishSymbol[]) {
this.children.push(...children);
children.forEach(child => {
child.parent = this;
});
return this;
}
addAliasedNames(...names: string[]) {
this.aliasedNames.push(...names);
return this;
}
private nameEqualsNodeText(node: SyntaxNode) {
return fishSymbolNameEqualsNodeText(this, node);
}
public isBefore(other: FishSymbol, urisMustMatch = true) {
if (this.uri !== other.uri) return !urisMustMatch;
return this.focusedNode.startIndex < other.focusedNode.startIndex;
}
public isAfter(other: FishSymbol, urisMustMatch = true) {
if (this.uri !== other.uri) return !urisMustMatch;
return this.focusedNode.startIndex > other.focusedNode.startIndex;
}
/**
* Returns the `argparse flag-name` for the symbol `_flag_flag_name`
*/
public get argparseFlagName() {
return FishSymbol.argparseFlagFromName(this.name);
}
/**
* Static method to convert a FishSymbol.isArgparse() with `_flag_variable_name` to `variable-name`
*/
public static argparseFlagFromName(name: string) {
return name.replace(/^_flag_/, '').replace(/_/g, '-');
}
/**
* Returns the argparse flag for the symbol, e.g. `-f` or `--flag-name`
*/
public get argparseFlag(): Flag | string {
if (this.fishKind !== 'ARGPARSE') return this.name;
const flagName = this.argparseFlagName;
if (flagName.length === 1) {
return `-${flagName}` as ShortFlag;
}
return `--${flagName}` as LongFlag;
}
/**
* Checks if an argparse _flag_name FishSymbol is equal to a SyntaxNode,
* where the SyntaxNode corresponds to the argparse
*
*
* ```fish
* function this.parent.name
* argparse f/flag-name -- $argv
* # ^^^^^^^^^^^---- This is the argparse flag name
* end
*
* complete -c this.parent.name -s f -l flag-name
* # ^ ^^^^^^^^^ Either of these could be the node (depending on the FishSymbol selected)
* ```
*
* @param node - The SyntaxNode to check against (`complete ... -s/-l NODE`)
* @return {boolean} - True if the node matches the argparse flag name, false otherwise
*/
private isArgparseCompletionFlag(node: SyntaxNode): boolean {
if (this.fishKind === 'ARGPARSE') return false;
if (node.parent && isCommandWithName(node, 'complete')) {
const flagName = this.argparseFlagName;
if (node.previousSibling) {
return flagName.length === 1
? Option.create('-s', '--short').matches(node.previousSibling)
: Option.create('-l', '--long').matches(node.previousSibling);
}
}
return false;
}
/**
* Checks if the node is a command completion flag, e.g. `complete -c NODE` or `complete --command NODE`
*/
private isCommandCompletionFlag(node: SyntaxNode) {
if (this.fishKind === 'COMPLETE') return false;
if (node.parent && isCommandWithName(node.parent, 'complete')) {
if (node.previousSibling) {
return Option.create('-c', '--command').matches(node.previousSibling);
}
}
return false;
}
isExported(): boolean {
if (this.fishKind === 'EVENT') return false;
if (this.fishKind === 'FUNCTION_EVENT') return false;
if (this.isFunction()) return false;
if (this.fishKind === 'FUNCTION_VARIABLE') return false;
if (!this.isVariable()) return false;
if (this.isArgparse()) return false;
if (this.fishKind === 'EXPORT') return true;
const commandNode = this.node;
if (isCommandWithName(commandNode, 'set')) {
const children = findSetChildren(commandNode)
.filter(s => s.startIndex < this.focusedNode.startIndex);
return children.some(s => isMatchingOption(s, Option.create('-x', '--export')));
}
if (isCommandWithName(commandNode, 'read')) {
const children = commandNode.children
.filter(s => s.startIndex < this.focusedNode.startIndex);
return children.some(s => isMatchingOption(s, Option.create('-x', '--export')));
}
return false;
}
isEqualLocation(node: SyntaxNode) {
if (!node.isNamed || this.focusedNode.equals(node) || !this.nameEqualsNodeText(node)) {
return false;
}
switch (this.fishKind) {
case 'FUNCTION':
case 'ALIAS':
return node.parent && isCommandWithName(node.parent, 'complete')
? !isVariableDefinitionName(node) && !isCommand(node) && this.isCommandCompletionFlag(node)
: !isVariableDefinitionName(node) && !isCommand(node);
case 'ARGPARSE':
// return !isFunctionDefinitionName(node) && isMatchingCompleteOptionIsCommand(node);
return !isFunctionDefinitionName(node) || this.isArgparseCompletionFlag(node);
case 'SET':
case 'READ':
case 'FOR':
case 'VARIABLE':
return !isFunctionDefinitionName(node);
case 'EXPORT':
return isExportVariableDefinitionName(node);
case 'FUNCTION_VARIABLE':
return isFunctionVariableDefinitionName(node);
case 'EVENT':
return isEmittedEventDefinitionName(node);
case 'FUNCTION_EVENT':
return isGenericFunctionEventHandlerDefinitionName(node);
case 'COMPLETE':
return isCompletionCommandDefinition(node) || isCompletionSymbol(node);
default:
return false;
}
}
/**
* Determines if the symbol requires local references to be found, which is used
* to skip matching diagnostics `4004`|`unused symbol` for certain matches.
*
* Examples include:
* - Functions which are autoloaded based on their path and file name.
* - Variables which are autoloaded based on their path.
* - Variables which are exported or global do not need local references.
* - Variables like `argv` and `fish_trace` do not need local references.
* - Variables like `for i in (seq 1 10); ;end;` do not need local references (iterate 10 times)
*
* @return {boolean} True if the symbol needs local references, false otherwise
*/
needsLocalReferences(): boolean {
if (this.isFunction()) {
// if function has a parent, it needs local references
if (!this.isRootLevel()) return true;
// if function is in a shebang script, and at root level, no local references needed
if (this.document.hasShebang()) return false;
// if function is autoloaded, global, and matches autoload name, no local references needed
if (
this.document.isAutoloaded() &&
this.isGlobal() &&
this.name === this.document.getAutoLoadName()
) return false;
// otherwise, function needs local references
return true;
}
if (this.fishKind === 'ALIAS') return false;
if (this.isVariable()) {
if (SKIPPABLE_VARIABLE_REFERENCE_NAMES.includes(this.name)) return false;
if (this.isExported()) return false;
if (this.isGlobal()) return false;
if (this.fishKind === 'FOR') return false;
return true;
}
return false;
}
skippableVariableName(): boolean {
if (!this.isVariable()) return false;
return SKIPPABLE_VARIABLE_REFERENCE_NAMES.includes(this.name);
}
get path() {
return uriToPath(this.uri);
}
get workspacePath() {
const path = this.path;
const pathItems = path.split('/');
let lastItem = pathItems.at(-1)!;
if (lastItem === 'config.fish') {
return pathItems.slice(0, -1).join('/');
}
lastItem = pathItems.at(-2)!;
if (['functions', 'completions', 'conf.d'].includes(lastItem)) {
return pathItems.slice(0, -2).join('/');
}
return pathItems.slice(0, -1).join('/');
}
get scopeTag() {
return this.scope.scopeTag;
}
/**
* Enclosing SyntaxNode for symbols constraint inside of a local document
* A global symbol will still have a scopeNode, but it should not be used to limit
* the scope of a symbol. It is more common to limit the scope of a Symbol based
* on if their is a redefined symbol (same name & type) inside of a smaller scope.
*/
get scopeNode() {
return this.scope.scopeNode;
}
// === Conversion Utils ===
toString() {
return SymbolConverters.symbolToString(this);
}
toWorkspaceSymbol(): WorkspaceSymbol {
return SymbolConverters.symbolToWorkspaceSymbol(this);
}
toDocumentSymbol(): DocumentSymbol | undefined {
return SymbolConverters.symbolToDocumentSymbol(this);
}
toLocation(): Location {
return SymbolConverters.symbolToLocation(this);
}
toPosition(): Position {
return SymbolConverters.symbolToPosition(this);
}
toFoldingRange(): FoldingRange {
return SymbolConverters.symbolToFoldingRange(this);
}
toMarkupContent(): MarkupContent {
return SymbolConverters.symbolToMarkupContent(this);
}
/**
* Optionally include the current document's uri to the hover, this will determine
* if a range is local to the current document (local ranges include hover range)
*/
toHover(currentUri: DocumentUri = ''): Hover {
return SymbolConverters.symbolToHover(this, currentUri);
}
// === FishSymbol type/location info ===
isLocal() {
return !this.isGlobal();
}
isGlobal() {
return this.scope.scopeTag === 'global' || this.scope.scopeTag === 'universal';
}
isAutoloaded() {
const doc = this.document.getAutoLoadName();
if (!doc) return false;
return this.name === doc && this.document.isAutoloaded() && this.isRootLevel();
}
isRootLevel() {
// return isTopLevelDefinition(this.node);
if (this.parent) {
return false;
}
return !this.parent;
}
isEventHook(): boolean {
return this.fishKind === 'FUNCTION_EVENT';
}
isEmittedEvent(): boolean {
return this.fishKind === 'EVENT';
}
isEvent(): boolean {
return FishKindGroups.EVENTS.includes(this.fishKind);
}
isFunction(): boolean {
return FishKindGroups.FUNCTIONS.includes(this.fishKind);
}
isVariable(): boolean {
return FishKindGroups.VARIABLES.includes(this.fishKind);
}
isArgparse(): boolean {
return FishKindGroups.ARGPARSE.includes(this.fishKind);
}
isSymbolImmutable() {
if (!config.fish_lsp_modifiable_paths.some(path => this.path.startsWith(path))) {
return true;
}
return false;
}
//
// Helpers for checking if the symbol is a fish_lsp_* config variable
//
/**
* Checks if the symbol is a key in the `config` object, which means it changes the
* configuration of the fish-lsp server.
*/
isConfigDefinition() {
if (this.kind !== SymbolKind.Variable || this.fishKind !== 'SET') {
return false;
}
return Object.keys(config).includes(this.name);
}
/**
* Checks if a config variable has the `--erase` option set
*/
isConfigDefinitionWithErase() {
if (!this.isConfigDefinition()) return false;
const eraseOption = Option.create('-e', '--erase');
const definitionNode = this.focusedNode;
const children = findSetChildren(this.node)
.filter(s => s.startIndex < definitionNode.startIndex);
return children.some(s => isMatchingOption(s, eraseOption));
}
/**
* Finds the value nodes of a config variable definition
*/
findValueNodes(): SyntaxNode[] {
const valueNodes: SyntaxNode[] = [];
if (!this.isConfigDefinition()) return valueNodes;
let node: null | SyntaxNode = this.focusedNode.nextNamedSibling;
while (node) {
if (!isEmptyString(node)) valueNodes.push(node);
node = node.nextNamedSibling;
}
return valueNodes;
}
/**
* Converts the value nodes of a config variable definition to shell values
*/
valuesAsShellValues() {
return this.findValueNodes().map(node => {
return SyncFileHelper.expandEnvVars(FishString.fromNode(node));
});
}
/**
* Checks if both the current & other symbol define the same argparse flag, when
* their is multiple equivalent _flag_names/_flag_n seen in the same argparse option.
*/
equalArgparse(other: FishSymbol | CompletionSymbol) {
if (FishSymbol.is(other)) {
const equalNames = this.name !== other.name && this.aliasedNames.includes(other.name) && other.aliasedNames.includes(this.name);
const equalParents = this.parent && other.parent
? this.parent.equals(other.parent)
: !this.parent && !other.parent;
return equalNames &&
this.uri === other.uri &&
this.fishKind === 'ARGPARSE' && other.fishKind === 'ARGPARSE' &&
this.focusedNode.equals(other.focusedNode) &&
this.node.equals(other.node) &&
equalParents &&
this.scopeNode.equals(other.scopeNode);
}
return false;
}
/**
* A function that is autoloaded and includes an `event` hook
*
* ```fish
* function my_function --on-event my_event
* # ^^^^^^^^^^^-------------------- my_function would return true
* end
* ```
*/
hasEventHook() {
if (!this.isFunction()) return false;
for (const child of this.children) {
if (child.isEventHook()) {
return true;
}
}
return false;
}
/**
* Checks if two symbols are equal events, excluding equality of the symbols
* equaling the exact same symbol. Also ensures that one of the Symbols is a
* event handler name, and the other is the emitted event name. Order does not
* matter, allowing for either symbol to be the event handler or the emitted event.
*
* ```fish
* function PARENT --on-event SYMBOL
* # ^^^^^^---- This is the event handler definition name
* end
*
* emit SYMBOL
* # ^^^^^^-------------------------- This is the emitted event definition name
* ```
*
* @param other - The other symbol to compare against
* @return {boolean} - True if the symbols are equal events, false otherwise
*
*/
equalsEvent(other: FishSymbol | CompletionSymbol): boolean {
if (!FishSymbol.is(other)) return false;
if (!this.isEvent() || !other.isEvent()) return false;
if (this.fishKind === other.fishKind) return false;
// parent of the `function PARENT --on-event SYMBOL`
const parent = this.fishKind === 'FUNCTION_EVENT'
? this.parent
: other.parent;
// child is the `emit SYMBOL` corresponding to the event in a function handler
const child = this.fishKind === 'EVENT'
? this
: other;
// check if the parent and child exist and have same name
return !!(parent && child && child.name === parent.name);
}
/**
* The heavy lifting utility to determine if a node is a reference to the current
* symbol.
*
* @param document The LspDocument to check against
* @param node The SyntaxNode to check
* @param excludeEqualNode If true, the node itself will not be considered a reference
*
* @returns {boolean} True if the node is a reference to the symbol, false otherwise
*/
isReference(document: LspDocument, node: SyntaxNode, excludeEqualNode = false): boolean {
return isSymbolReference(this, document, node, excludeEqualNode);
}
/**
* Checks if 2 symbols are the same, based on their properties.
*/
equals(other: FishSymbol): boolean {
return equalSymbols(this, other);
}
/**
* Checks if the symbol is the location.
*/
equalsLocation(location: Location): boolean {
return symbolEqualsLocation(this, location);
}
/**
* Checks if a Symbol is defined in the same scope as its comparison symbol.
*/
equalDefinition(other: FishSymbol): boolean {
return equalSymbolDefinitions(this, other);
}
/**
* Checks if the symbol is equal to the SyntaxNode
* @param node The SyntaxNode to compare against
* @param opts.strict If true, the comparison will be strict, meaning the node must match the symbol's focusedNode
* Otherwise, a match can be either the focusedNode or the node itself.
* @returns {boolean} True if the symbol is equal to the node, false otherwise
*/
equalsNode(node: SyntaxNode, opts: { strict?: boolean; } = { strict: false }): boolean {
return symbolEqualsNode(this, node, opts.strict);
}
/**
* Checks if the symbol contains the other symbol's scope.
* Here, the current Symbol must be ATLEAST equivalent parents to the other symbol
* when the other symbol's Scope is not greater than the current symbol's scope.
*/
containsScope(other: FishSymbol): boolean {
return symbolContainsScope(this, other);
}
/**
* Checks if the symbol has the same scope as the other symbol.
*/
equalScopes(other: FishSymbol): boolean {
return equalSymbolScopes(this, other);
}
/**
* Checks if the symbol contains the node in its scope.
*/
scopeContainsNode(node: SyntaxNode): boolean {
return symbolScopeContainsNode(this, node);
}
/**
* Checks if the symbol.range contains or is equal to the node's range.
*/
containsNode(node: SyntaxNode): boolean {
return symbolContainsNode(this, node);
}
/**
* Check if the current symbols position contains or is equal to the given position
* @param position The position to check against
* @return {boolean} True if the symbol contains the position, false otherwise
*/
containsPosition(position: { line: number; character: number; }): boolean {
return symbolContainsPosition(this, position);
}
}
export type ModifierScopeTag = 'universal' | 'global' | 'function' | 'local' | 'inherit';
export const SetModifierToScopeTag = (modifier: Option): ModifierScopeTag => {
switch (true) {
case modifier.isOption('-U', '--universal'):
return 'universal';
case modifier.isOption('-g', '--global'):
return 'global';
case modifier.isOption('-f', '--function'):
return 'function';
case modifier.isOption('-l', '--local'):
return 'local';
default:
return 'local';
}
};
export {
FishSymbolKind,
fromFishSymbolKindToSymbolKind,
FishKindGroups,
fishSymbolKindToSymbolKind,
};
export function filterLastPerScopeSymbol(symbols: FishSymbol[]) {
const flatArray: FishSymbol[] = flattenNested(...symbols);
const array: FishSymbol[] = [];
for (const symbol of symbols) {
const lastSymbol = flatArray.findLast((s: FishSymbol) => {
return s.name === symbol.name && s.kind === symbol.kind && s.uri === symbol.uri
&& s.equalScopes(symbol);
});
if (lastSymbol && lastSymbol.equals(symbol)) {
array.push(symbol);
}
}
return array;
}
export function filterFirstPerScopeSymbol(document: LspDocument | DocumentUri): FishSymbol[] {
const uri: DocumentUri = LspDocument.is(document) ? document.uri : document;
const symbols = analyzer.getFlatDocumentSymbols(uri);
const flatArray: FishSymbol[] = Array.from(symbols);
const array: FishSymbol[] = [];
for (const symbol of symbols) {
const firstSymbol = flatArray.find((s: FishSymbol) => s.equalDefinition(symbol));
if (firstSymbol && firstSymbol.equals(symbol)) {
array.push(symbol);
}
}
return array;
}
export function filterFirstUniqueSymbolperScope(document: LspDocument | DocumentUri): FishSymbol[] {
const uri: DocumentUri = LspDocument.is(document) ? document.uri : document;
const symbols = analyzer.getFlatDocumentSymbols(uri);
const result: FishSymbol[] = [];
for (const symbol of symbols) {
const alreadyExists = result.some(existing =>
existing.name === symbol.name && existing.equalDefinition(symbol),
);
if (!alreadyExists) {
result.push(symbol);
}
}
return result;
}
export function findLocalLocations(symbol: FishSymbol, allSymbols: FishSymbol[], includeSelf = true): Location[] {
const result: SyntaxNode[] = [];
/*
* Here we need to handle aliases where there exists a function with the same name
* (A very weird edge case)
*/
const matchingNodes = allSymbols.filter(s => s.name === symbol.name && !symbol.equalScopes(s))
.map(s => symbol.fishKind === 'ALIAS' ? s.node : s.scopeNode);
for (const node of getChildNodes(symbol.scopeNode)) {
/** skip nodes that would be considered a match for another symbol */
if (matchingNodes.some(n => containsNode(n, node))) continue;
if (symbol.isEqualLocation(node)) result.push(node);
}
return [
includeSelf && symbol.name !== 'argv' ? symbol.toLocation() : undefined,
...result.map(node => symbol.fishKind === 'ARGPARSE'
? Location.create(symbol.uri, convertNodeRangeWithPrecedingFlag(node))
: Location.create(symbol.uri, getRange(node)),
),
].filter(Boolean) as Location[];
}
/**
* Formats a tree of FishSymbols into a string with proper indentation
* @param symbols Array of FishSymbol objects to format
* @param indentLevel Initial indentation level (optional, defaults to 0)
* @returns A string representing the formatted tree
*/
export function formatFishSymbolTree(symbols: FishSymbol[], indentLevel: number = 0): string {
let result = '';
const indentString = ' '; // 2 spaces per indent level
for (const symbol of symbols) {
const indent = indentString.repeat(indentLevel);
const scopeTag = symbol.scope?.scopeTag || 'unknown';
result += `${indent}${symbol.name} (${symbol.fishKind}) (${scopeTag})\n`;
// Recursively format children with increased indent
if (symbol.children && symbol.children.length > 0) {
result += formatFishSymbolTree(symbol.children, indentLevel + 1);
}
}
return result;
}
function buildNested(document: LspDocument, node: SyntaxNode, ...children: FishSymbol[]): FishSymbol[] {
const firstNamedChild = node.firstNamedChild as SyntaxNode;
const newSymbols: FishSymbol[] = [];
switch (node.type) {
case 'function_definition':
newSymbols.push(...processFunctionDefinition(document, node, children));
break;
case 'for_statement':
newSymbols.push(...processForDefinition(document, node, children));
break;
case 'command':
if (isInlineVariableAssignment(node)) {
// Inline variable assignments are handled elsewhere
newSymbols.push(...processInlineVariables(document, node));
break;
}
if (!firstNamedChild?.text) break;
switch (firstNamedChild.text) {
case 'set':
newSymbols.push(...processSetCommand(document, node, children));
break;
case 'read':
newSymbols.push(...processReadCommand(document, node, children));
break;
case 'argparse':
newSymbols.push(...processArgparseCommand(document, node, children));
break;
case 'alias':
newSymbols.push(...processAliasCommand(document, node, children));
break;
case 'export':
newSymbols.push(...processExportCommand(document, node, children));
break;
case 'emit':
newSymbols.push(...processEmitEventCommandName(document, node, children));
break;
default:
break;
}
break;
}
return newSymbols;
}
export type NestedFishSymbolTree = FishSymbol[];
export type FlatFishSymbolTree = FishSymbol[];
export function processNestedTree(document: LspDocument, ...nodes: SyntaxNode[]): NestedFishSymbolTree {
const symbols: FishSymbol[] = [];
/** add argv to script files */
if (!document.isAutoloadedUri()) {
const programNode = nodes.find(node => node.type === 'program');
if (programNode) symbols.push(...processArgvDefinition(document, programNode));
}
for (const node of nodes) {
// Process children first (bottom-up approach)
const childSymbols = processNestedTree(document, ...node.children);
// Process the current node and integrate children
const newSymbols = buildNested(document, node, ...childSymbols);
if (newSymbols.length > 0) {
// If we created symbols for this node, add them (they should contain children)
symbols.push(...newSymbols);
} else if (childSymbols.length > 0) {
// If no new symbols from this node but we have child symbols, bubble them up
symbols.push(...childSymbols);
}
// If neither condition is met, we add nothing
}
return symbols;
}
================================================
FILE: src/parsing/unreachable.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { isCommand, isReturn, isSwitchStatement, isCaseClause, isIfStatement, isForLoop, isFunctionDefinition, isComment, isConditionalCommand } from '../utils/node-types';
/**
* Checks if a node represents a control flow statement that terminates execution
*/
function isTerminalStatement(node: SyntaxNode): boolean {
if (isReturn(node)) return true;
if (isCommand(node)) {
const commandName = node.firstNamedChild?.text;
return commandName === 'exit' || commandName === 'break' || commandName === 'continue';
}
// Also check if the node itself is a break/continue/exit/return keyword
if (node.type === 'break' || node.type === 'continue' || node.type === 'exit' || node.type === 'return') {
return true;
}
return false;
}
/**
* Checks if a conditional_execution node contains a terminal statement
*/
function conditionalExecutionTerminates(conditionalNode: SyntaxNode): boolean {
// conditional_execution nodes directly contain the terminal statement
// e.g., (conditional_execution (return (integer)))
for (const child of conditionalNode.namedChildren) {
if (isTerminalStatement(child)) {
return true;
}
}
return false;
}
/**
* Checks if a conditional_execution represents an 'and' or 'or' operation
* Fish uses both keyword forms (and/or) and operator forms (&&/||)
*/
function getConditionalType(node: SyntaxNode): 'and' | 'or' | null {
// Check all children for the operator (can be named or unnamed)
for (const child of node.children) {
// Fish keyword forms: 'and' or 'or' (named nodes)
if (child.type === 'and') return 'and';
if (child.type === 'or') return 'or';
// Operator forms: '&&' or '||' (unnamed tokens)
if (!child.isNamed) {
if (child.text === '&&') return 'and';
if (child.text === '||') return 'or';
}
}
return null;
}
/**
* Checks if a sequence of statements forms a complete and/or chain that terminates all paths
* Pattern: command + and + or (or command + or + and) where both conditional branches terminate
*
* Example of unreachable:
* echo a
* and return 0
* or return 1
* echo "unreachable" # Both success and failure paths exit
*
* Example of reachable:
* git rev-parse || return
* echo "reachable" # Only failure path exits, success continues
*/
function sequenceFormsTerminatingAndOrChain(nodes: SyntaxNode[], startIndex: number): boolean {
// Need at least 3 nodes: initial command + and branch + or branch
if (startIndex + 2 >= nodes.length) return false;
const first = nodes[startIndex];
const second = nodes[startIndex + 1];
const third = nodes[startIndex + 2];
// Pattern: command followed by two conditional_execution nodes
if (!first || !second || !third) return false;
const isCommandSequence = (isCommand(first) || isConditionalCommand(first)) &&
isConditionalCommand(second) &&
isConditionalCommand(third);
if (!isCommandSequence) return false;
// CRITICAL FIX: Must have BOTH 'and' and 'or' to terminate all paths
// If we only have 'or' (or only 'and'), one path continues execution
const secondType = getConditionalType(second);
const thirdType = getConditionalType(third);
// Must have both && and || (in either order)
const hasBothOperators = secondType === 'and' && thirdType === 'or' ||
secondType === 'or' && thirdType === 'and';
if (!hasBothOperators) return false;
// Both conditional executions must terminate
const secondTerminates = conditionalExecutionTerminates(second);
const thirdTerminates = conditionalExecutionTerminates(third);
return secondTerminates && thirdTerminates;
}
/**
* Checks if a case clause contains a terminal statement
*/
function caseContainsTerminalStatement(caseNode: SyntaxNode): boolean {
// Look through all children of the case clause (excluding the pattern)
const caseBodyNodes: SyntaxNode[] = [];
let skipPattern = true;
for (const child of caseNode.namedChildren) {
if (skipPattern) {
skipPattern = false; // Skip the first child (the pattern)
continue;
}
caseBodyNodes.push(child);
}
// Check if the sequence of statements in this case terminates all paths
return sequenceTerminatesAllPaths(caseBodyNodes);
}
/**
* Checks if a sequence of statements terminates all possible execution paths
* This is the core logic for determining if code after this sequence is unreachable
*/
function sequenceTerminatesAllPaths(nodes: SyntaxNode[]): boolean {
for (const node of nodes) {
// Skip comments
if (isComment(node)) {
continue;
}
// Direct terminal statements
if (isTerminalStatement(node)) {
return true;
}
// Complete if/else statements where all paths terminate
if (isIfStatement(node) && allPathsTerminate(node)) {
return true;
}
// Complete switch statements where all paths terminate
if (isSwitchStatement(node) && allSwitchPathsTerminate(node)) {
return true;
}
}
return false;
}
/**
* Checks if all code paths in an if statement terminate
*/
function allPathsTerminate(ifNode: SyntaxNode): boolean {
let hasElse = false;
let ifBodyTerminates = false;
let elseBodyTerminates = false;
// Extract the different parts of the if statement
const ifBodyNodes: SyntaxNode[] = [];
let elseClauseNode: SyntaxNode | null = null;
let skipCondition = true;
for (const child of ifNode.namedChildren) {
// Skip the condition parts (only the first condition)
if (skipCondition && (child.type === 'command' || child.type === 'test_command' || child.type === 'command_substitution')) {
skipCondition = false; // Only skip the very first condition
continue;
}
// Check else clause
if (child.type === 'else_clause') {
hasElse = true;
elseClauseNode = child;
} else if (child.type !== 'else_if_clause') {
// This is part of the if body
ifBodyNodes.push(child);
}
}
// Check if the if body terminates - must check if the sequence of statements terminates
ifBodyTerminates = sequenceTerminatesAllPaths(ifBodyNodes);
// Check if the else body terminates
if (hasElse && elseClauseNode) {
const elseBodyNodes = Array.from(elseClauseNode.namedChildren);
elseBodyTerminates = sequenceTerminatesAllPaths(elseBodyNodes);
}
return ifBodyTerminates && hasElse && elseBodyTerminates;
}
/**
* Checks if all paths in a switch statement terminate
*/
function allSwitchPathsTerminate(switchNode: SyntaxNode): boolean {
let hasDefault = false;
let allCasesTerminate = true;
for (const child of switchNode.namedChildren) {
if (isCaseClause(child)) {
// Check if this is the default case - look for '*' pattern
const casePattern = child.firstNamedChild?.text;
if (casePattern === '*' || casePattern === '"*"' || casePattern === "'*'" || casePattern === '\\*') {
hasDefault = true;
}
// Check if this case terminates
if (!caseContainsTerminalStatement(child)) {
allCasesTerminate = false;
}
}
}
return hasDefault && allCasesTerminate;
}
/**
* Gets all unreachable statements after a terminal statement in a sequence
*/
function getUnreachableStatementsInSequence(nodes: SyntaxNode[]): SyntaxNode[] {
const unreachable: SyntaxNode[] = [];
let foundTerminal = false;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!;
// Skip comments - they're allowed after terminal statements
if (isComment(node)) {
continue;
}
if (foundTerminal) {
unreachable.push(node);
continue;
}
// Check for direct terminal statements
if (isTerminalStatement(node)) {
foundTerminal = true;
continue;
}
// Check for control structures that terminate all paths
if (isIfStatement(node) && allPathsTerminate(node)) {
foundTerminal = true;
continue;
}
if (isSwitchStatement(node) && allSwitchPathsTerminate(node)) {
foundTerminal = true;
continue;
}
// Check for and/or chains: command + conditional_execution + conditional_execution
if (sequenceFormsTerminatingAndOrChain(nodes, i)) {
foundTerminal = true;
// Skip the next 2 nodes since they're part of this pattern
i += 2;
continue;
}
}
return unreachable;
}
/**
* Finds unreachable code nodes in a function definition
*/
function findUnreachableInFunction(functionNode: SyntaxNode): SyntaxNode[] {
const unreachable: SyntaxNode[] = [];
// Get the function body (all children except the function keyword and name)
const functionBodyNodes: SyntaxNode[] = [];
let foundFunctionKeyword = false;
let foundFunctionName = false;
for (const child of functionNode.namedChildren) {
// Skip function keyword
if (!foundFunctionKeyword && child.type === 'word' && child.text === 'function') {
foundFunctionKeyword = true;
continue;
}
// Skip function name (first word after 'function')
if (foundFunctionKeyword && !foundFunctionName && child.type === 'word') {
foundFunctionName = true;
continue;
}
// Skip comments - they don't affect control flow
if (isComment(child)) {
continue;
}
functionBodyNodes.push(child);
}
// Find unreachable statements in the function body
unreachable.push(...getUnreachableStatementsInSequence(functionBodyNodes));
return unreachable;
}
/**
* Finds unreachable code nodes in any block scope (if, for, etc.)
*/
function findUnreachableInBlock(blockNode: SyntaxNode): SyntaxNode[] {
const unreachable: SyntaxNode[] = [];
// For if statements, we need to check each branch separately
if (isIfStatement(blockNode)) {
const ifBodyNodes: SyntaxNode[] = [];
let elseClauseNode: SyntaxNode | null = null;
let skipCondition = true;
// Extract if body and else clause
for (const child of blockNode.namedChildren) {
// Skip only the FIRST condition part
if (skipCondition && (child.type === 'command' || child.type === 'test_command' || child.type === 'command_substitution')) {
skipCondition = false; // Only skip the very first condition
continue;
}
if (child.type === 'else_clause') {
elseClauseNode = child;
} else if (child.type !== 'else_if_clause') {
// This is part of the if body
ifBodyNodes.push(child);
}
}
// Check for unreachable code in the if body
unreachable.push(...getUnreachableStatementsInSequence(ifBodyNodes));
// Check for unreachable code in the else clause
if (elseClauseNode) {
const elseBodyNodes = Array.from(elseClauseNode.namedChildren);
unreachable.push(...getUnreachableStatementsInSequence(elseBodyNodes));
}
} else if (isForLoop(blockNode)) {
// For loops: skip the iterator variable and iterable, get the body
const loopBodyNodes: SyntaxNode[] = [];
let skipForParts = true;
for (const child of blockNode.namedChildren) {
// Skip "for var in iterable" parts
if (skipForParts && (child.type === 'variable_name' || child.type === 'word' || child.type === 'command_substitution' || child.type === 'concatenation')) {
continue;
}
skipForParts = false;
loopBodyNodes.push(child);
}
unreachable.push(...getUnreachableStatementsInSequence(loopBodyNodes));
} else {
// For other block types, include all children
const blockBodyNodes = Array.from(blockNode.namedChildren);
unreachable.push(...getUnreachableStatementsInSequence(blockBodyNodes));
}
return unreachable;
}
/**
* Recursively find unreachable code in a node and its descendants
* This is more efficient than getChildNodes() because it only visits relevant nodes
*/
function findUnreachableRecursive(node: SyntaxNode, unreachable: SyntaxNode[]): void {
// Check the node itself for unreachable code
if (isFunctionDefinition(node)) {
unreachable.push(...findUnreachableInFunction(node));
} else if (isIfStatement(node) || isForLoop(node)) {
unreachable.push(...findUnreachableInBlock(node));
}
// Recursively check named children
// This is much faster than getChildNodes() which does BFS over entire tree
for (const child of node.namedChildren) {
// Skip comments as they don't affect control flow
if (isComment(child)) continue;
// Recursively process child
findUnreachableRecursive(child, unreachable);
}
}
/**
* Main function to find unreachable code nodes starting from a root node
* Optimized to avoid full tree traversal via getChildNodes()
*/
export function findUnreachableCode(root: SyntaxNode): SyntaxNode[] {
const unreachable: SyntaxNode[] = [];
// Handle top-level program statements
if (root.type === 'program') {
const topLevelNodes = Array.from(root.namedChildren).filter(child => !isComment(child));
const topLevelUnreachable = getUnreachableStatementsInSequence(topLevelNodes);
unreachable.push(...topLevelUnreachable);
}
// Recursively traverse the tree (much faster than getChildNodes())
findUnreachableRecursive(root, unreachable);
return unreachable;
}
================================================
FILE: src/parsing/values.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { FishSymbol } from './symbol';
import { isMatchingOption, isString } from '../utils/node-types';
import { FishString } from './string';
import { config } from '../config';
import { findSetChildren } from './set';
import { Option } from './options';
import { SyncFileHelper } from '../utils/file-operations';
import { SymbolKind } from 'vscode-languageserver';
/**
* Current implementation is for evaluating `config` keys, in non autoloaded
* paths, but shown in the current workspace/document, in client.
*
* This will retrieve the values seen in a `set` definition, without
* needing to extranally evaluate the definition via fish's source
* command.
*
* Potential further implementation is ahead.
*/
export namespace LocalFishLspDocumentVariable {
export function isConfigVariableDefinition(symbol: FishSymbol): boolean {
if (symbol.kind !== SymbolKind.Variable || symbol.fishKind !== 'SET') {
return false;
}
return Object.keys(config).includes(symbol.name);
}
export function isConfigVariableDefinitionWithErase(
symbol: FishSymbol,
): boolean {
if (!symbol.isConfigDefinition()) {
return false;
}
return hasEraseFlag(symbol);
}
export function findValueNodes(symbol: FishSymbol) {
const valueNodes: SyntaxNode[] = [];
if (!symbol.isConfigDefinition()) return valueNodes;
let node: null | SyntaxNode = symbol.focusedNode.nextNamedSibling;
while (node) {
if (!isEmptyString(node)) valueNodes.push(node);
node = node.nextNamedSibling;
}
return valueNodes;
}
export function nodeToShellValue(node: SyntaxNode): string {
return SyncFileHelper.expandEnvVars(FishString.fromNode(node));
}
export const eraseOption = Option.create('-e', '--erase');
export function hasEraseFlag(symbol: FishSymbol): boolean {
const definitionNode = symbol.focusedNode;
// get only the flags. these are only allowed between the command `set` and the `variable_name`
// i.e., set -gx foo value_1 || set --global --erase foo value_2
// ^^^ ^^^^^^^^ ^^^^^^^ are the only matches
const children = findSetChildren(symbol.node)
.filter(s => s.startIndex < definitionNode.startIndex);
return children.some(s => isMatchingOption(s, eraseOption));
}
}
function isEmptyString(node: SyntaxNode) {
return isString(node) && node.text.length === 2;
}
export function configDefinitionParser(
symbol: FishSymbol,
) {
const isDefinition = LocalFishLspDocumentVariable.isConfigVariableDefinition(symbol);
const isDefinitionWithErase = LocalFishLspDocumentVariable.isConfigVariableDefinitionWithErase(symbol);
const valueNodes = LocalFishLspDocumentVariable.findValueNodes(symbol);
const values = valueNodes.map(node => LocalFishLspDocumentVariable.nodeToShellValue(node));
return {
isDefinition,
isErase: isDefinitionWithErase,
valueNodes,
values,
};
}
================================================
FILE: src/references.ts
================================================
import { DocumentUri, Location, Position, Range, WorkDoneProgressReporter } from 'vscode-languageserver';
import { analyzer } from './analyze';
import { LspDocument } from './document';
import { findParentCommand, findParentFunction, isCommandName, isCommandWithName, isMatchingOption, isOption, isProgram, isString } from './utils/node-types';
import { containsNode, getRange, nodesGen } from './utils/tree-sitter';
import { filterFirstPerScopeSymbol, FishSymbol } from './parsing/symbol';
import { isMatchingOptionOrOptionValue, Option } from './parsing/options';
import { logger } from './logger';
import { getGlobalArgparseLocations } from './parsing/argparse';
import { SyntaxNode } from 'web-tree-sitter';
import * as Locations from './utils/locations';
import { Workspace } from './utils/workspace';
import { workspaceManager } from './utils/workspace-manager';
import { uriToReadablePath } from './utils/translation';
import { FishAlias, isAliasDefinitionValue } from './parsing/alias';
import { extractCommandLocations, extractCommands, extractMatchingCommandLocations } from './parsing/nested-strings';
import { isEmittedEventDefinitionName } from './parsing/emit';
// ┌──────────────────────────────────┐
// │ file handles 3 main operations: │
// │ • getReferences() │
// │ • allUnusedLocalReferences() │
// │ • getImplementations() │
// └──────────────────────────────────┘
/**
* Options for the getReferences function
*/
export type ReferenceOptions = {
// don't include the definition of the symbol itself
excludeDefinition?: boolean;
// only check local references inside the current document
localOnly?: boolean;
// stop searching after the first match
firstMatch?: boolean;
// search in all workspaces, default is to search only the current workspace
allWorkspaces?: boolean;
// only consider matches in the specified files
onlyInFiles?: ('conf.d' | 'functions' | 'config' | 'completions')[];
// log performance, show timing of the function
logPerformance?: boolean;
// enable logging for the function
loggingEnabled?: boolean;
reporter?: WorkDoneProgressReporter; // callback to report the number of references found
};
/**
* get all the references for a symbol, including the symbol's definition
* @param analyzer the analyzer
* @param document the document
* @param position the position of the symbol
* @param localOnly if true, only return local references inside current document
* @return the locations of the symbol
*/
export function getReferences(
document: LspDocument,
position: Position,
opts: ReferenceOptions = {
excludeDefinition: false,
localOnly: false,
firstMatch: false,
allWorkspaces: false,
onlyInFiles: [],
logPerformance: true,
loggingEnabled: false,
reporter: undefined,
},
): Location[] {
const results: Location[] = [];
const logCallback = logWrapper(document, position, opts);
// Get the Definition Symbol of the current position, if there isn't one
// we can't find any references
const definitionSymbol = analyzer.getDefinition(document, position);
if (!definitionSymbol) {
logCallback(
`No definition symbol found for position ${JSON.stringify(position)} in document ${document.uri}`,
'warning',
);
return [];
}
// include the definition symbol itself
if (!opts.excludeDefinition) results.push(definitionSymbol.toLocation());
// if the symbol is local, we only search in the current document
if (isSymbolLocalToDocument(definitionSymbol)) opts.localOnly = true;
// create a list of al documents we will search for references
const documentsToSearch: LspDocument[] = getDocumentsToSearch(document, logCallback, opts);
// analyze the CompletionSymbol's and add their locations to result array
// this is separate from the search operation because analysis lazy loads
// completion documents (completion files are skipped during the initial workspace load)
if (definitionSymbol.isArgparse() || definitionSymbol.isFunction()) {
results.push(...getGlobalArgparseLocations(definitionSymbol.document, definitionSymbol));
}
if (
definitionSymbol.isFunction()
&& definitionSymbol.hasEventHook()
&& definitionSymbol.document.isAutoloaded()
) {
results.push(...analyzer.findSymbols((d, _) => {
if (d.isEmittedEvent() && d.name === definitionSymbol.name) {
return true;
}
return false;
}).map(d => d.toLocation()));
}
// convert the documentsToSearch to a Set for O(1) lookups
const searchableDocumentsUris = new Set(documentsToSearch.map(doc => doc.uri));
const searchableDocuments = new Set(documentsToSearch.filter(doc => searchableDocumentsUris.has(doc.uri)));
// dictionary where we will store the references found, used to build the results
const matchingNodes: { [document: DocumentUri]: SyntaxNode[]; } = {};
// boolean to control stopping our search when opts.firstMatch is true
let shouldExitEarly = false;
// utils for reporting progress during large searches of references
let reporting = false;
const reporter = opts.reporter;
// if we have a reporter, we will report the progress of the search
if (opts.reporter && searchableDocuments.size > 500) {
reporter?.begin('[fish-lsp] finding references', 0, 'Finding references...', true);
reporting = true;
}
let index = 0;
// search the valid documents for references and store matches to build after
// we have collected all valid matches for the requested options
for (const doc of searchableDocuments) {
const prog = Math.ceil((index + 1) / searchableDocuments.size * 100);
if (reporting) {
reporter?.report(prog);
}
index += 1;
if (!workspaceManager.current?.contains(doc.uri)) {
continue;
}
const filteredSymbols = getFilteredLocalSymbols(definitionSymbol, doc);
const root = analyzer.getRootNode(doc.uri);
if (!root) {
logCallback(`No root node found for document ${doc.uri}`, 'warning');
continue;
}
const matchableNodes = getChildNodesOptimized(definitionSymbol, doc);
for (const node of matchableNodes) {
// skip nodes that are redefinitions of the symbol in the local scope
if (filteredSymbols && filteredSymbols.some(s => s.containsNode(node) || s.scopeNode.equals(node) || s.scopeContainsNode(node))) {
continue;
}
// store matches in the matchingNodes dictionary
if (definitionSymbol.isReference(doc, node, true)) {
const currentDocumentsNodes = matchingNodes[doc.uri] ?? [];
currentDocumentsNodes.push(node);
matchingNodes[doc.uri] = currentDocumentsNodes;
if (opts.firstMatch) {
shouldExitEarly = true; // stop searching after the first match
break;
}
}
}
if (shouldExitEarly) break;
}
// now convert the matching nodes to locations
for (const [uri, nodes] of Object.entries(matchingNodes)) {
for (const node of nodes) {
const locations = getLocationWrapper(definitionSymbol, node, uri)
.filter(loc => !results.some(location => Locations.Location.equals(loc, location)));
results.push(...locations);
}
}
// log the results, if logging option is enabled
const docShorthand = `${workspaceManager.current?.name}`;
const count = results.length;
const name = definitionSymbol.name;
logCallback(
`Found ${count} references for symbol '${name}' in document '${docShorthand}'`,
'info',
);
if (reporting) reporter?.done();
const sorter = locationSorter(definitionSymbol);
return results.sort(sorter);
}
/**
* Returns all unused local references in the current document.
*/
export function allUnusedLocalReferences(document: LspDocument): FishSymbol[] {
// const allSymbols = analyzer.getFlatDocumentSymbols(document.uri);
const symbols = filterFirstPerScopeSymbol(document).filter(s =>
s.isLocal()
&& s.name !== 'argv'
&& !s.isEventHook()
&& !s.isExported(),
);
if (!symbols) return [];
const usedSymbols: FishSymbol[] = [];
const unusedSymbols: FishSymbol[] = [];
for (const symbol of symbols) {
const localSymbols = getFilteredLocalSymbols(symbol, document);
let found = false;
const root = analyzer.getRootNode(document.uri);
if (!root) {
logger.warning(`No root node found for document ${document.uri}`);
continue;
}
for (const node of nodesGen(root)) {
// skip nodes that are redefinitions of the symbol in the local scope
if (localSymbols?.some(c => c.scopeContainsNode(node))) {
continue;
}
if (symbol.isReference(document, node, true)) {
found = true;
usedSymbols.push(symbol);
break;
}
}
if (!found) unusedSymbols.push(symbol);
}
// Confirm that the unused symbols are not referenced by any used symbols for edge cases
// where names don't match, but the symbols are meant to overlap in usage:
//
// `argparse h/help`/`_flag_h`/`_flag_help`/`complete -s h -l help`
// `function event_handler --on-event my_event`/`emit my_event # usage of event_handler`
//
const finalUnusedSymbols = unusedSymbols.filter(symbol => {
if (symbol.isArgparse() && usedSymbols.some(s => s.equalArgparse(symbol))) {
return false;
}
if (symbol.hasEventHook()) {
if (symbol.isGlobal()) return false;
if (
symbol.isLocal()
&& symbol.children.some(c => c.fishKind === 'FUNCTION_EVENT' && usedSymbols.some(s => s.isEmittedEvent() && c.name === s.name))
) {
return false;
}
// for a function that should be treated locally, but a event that is emitted globally in another doc
if (symbol.document.isAutoloaded() && symbol.isFunction() && symbol.hasEventHook()) {
const eventsEmitted = symbol.children.filter(c => c.isEventHook());
for (const event of eventsEmitted) {
if (analyzer.findNode(n => isEmittedEventDefinitionName(n) && n.text === event.name)) {
return false;
}
}
}
}
return true;
});
logger.debug({
usage: 'finalUnusedLocalReferences',
finalUnusedSymbols: finalUnusedSymbols.map(s => s.name),
});
return finalUnusedSymbols;
}
/**
* bi-directional jump to either definition or completion definition
* @param analyzer the analyzer
* @param document the document
* @param position the position of the symbol
* @return the locations of the symbol, should be a lower number of locations than getReferences
*/
export function getImplementation(
document: LspDocument,
position: Position,
): Location[] {
const locations: Location[] = [];
const node = analyzer.nodeAtPoint(document.uri, position.line, position.character);
if (!node) return [];
const symbol = analyzer.getDefinition(document, position);
if (!symbol) return [];
if (symbol.isEmittedEvent()) {
const result = analyzer.findSymbol((s, _) =>
s.isEventHook() && s.name === symbol.name,
)?.toLocation();
if (result) {
locations.push(result);
return locations;
}
}
if (symbol.isEventHook()) {
const result = analyzer.findSymbol((s, _) =>
s.isEmittedEvent() && s.name === symbol.name,
)?.toLocation();
if (result) {
locations.push(result);
return locations;
}
}
const newLocations = getReferences(document, position)
.filter(location => location.uri !== document.uri);
if (newLocations.some(s => s.uri === symbol.uri)) {
locations.push(symbol.toLocation());
return locations;
}
if (newLocations.some(s => s.uri.includes('completions/'))) {
locations.push(newLocations.find(s => s.uri.includes('completions/'))!);
return locations;
}
locations.push(symbol.toLocation());
return locations;
}
/**
* Returns the location of a node, based on the symbol.
* Handles special cases where a reference might be part of a larger token from tree-sitter.
*
* For example, in argparse switches, we want to return the location of the flag name which
* might include a short flag and a long flag like:
*
* ```fish
* argparse h/help -- $argv # we might want 'h' or 'help' specifically, fish tokenizes the 'h/help' together
* ```
*
* @param symbol the definition symbol for which we are searching for references
* @param node the tree-sitter node that matches the symbol
* @param uri the document URI of the node (for global symbols, the URI might not match the symbol's URI)
* @return an array of locations for the node, most commonly a single item is returned in the array
*/
function getLocationWrapper(symbol: FishSymbol, node: SyntaxNode, uri: DocumentUri): Location[] {
let range = getRange(node);
// for argparse flags, we want the range of the flag name, not the whole option
if (symbol.fishKind === 'ARGPARSE' && isOption(node)) {
range = {
start: {
line: range.start.line,
character: range.start.character + getLeadingDashCount(node),
},
end: {
line: range.end.line,
character: range.end.character + 1,
},
};
return [Location.create(uri, range)];
}
if (isAliasDefinitionValue(node)) {
const parent = findParentCommand(node);
if (!parent) return [];
const info = FishAlias.getInfo(parent);
if (!info) return [];
const aliasRange = extractCommandRangeFromAliasValue(node, symbol.name);
if (aliasRange) {
range = aliasRange;
}
return [Location.create(uri, range)];
}
if (NestedSyntaxNodeWithReferences.isBindCall(symbol, node)) {
return extractMatchingCommandLocations(symbol, node, uri);
}
if (NestedSyntaxNodeWithReferences.isCompleteConditionCall(symbol, node)) {
return extractMatchingCommandLocations(symbol, node, uri);
}
if (symbol.isFunction() && (isString(node) || isOption(node))) {
return extractCommandLocations(node, uri)
.filter(loc => loc.command === symbol.name)
.map(loc => loc.location);
}
return [Location.create(uri, range)];
}
/**
* Counts the number of leading dashes in a node's text
* This is used to determine the range of an option flag in an argparse's completion or usage
* @param node the completion node to check
* @return the number of leading dashes in the node's text
*/
function getLeadingDashCount(node: SyntaxNode): number {
if (!node || !node.text) return 0;
const text = node.text;
let count = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '-') {
count++;
} else {
break;
}
}
return count;
}
/**
* Namespace for checking SyntaxNode references are of a specific type
* • `alias foo=''`
* • `bind ctrl-space ''`
* • `complete -c foo -n '' -xa '1 2 3'`
*/
export namespace NestedSyntaxNodeWithReferences {
export function isAliasValueNode(definitionSymbol: FishSymbol, node: SyntaxNode): boolean {
if (!isAliasDefinitionValue(node)) return false;
const parent = findParentCommand(node);
if (!parent) return false;
const info = FishAlias.getInfo(parent);
if (!info) return false;
const infoCmds = info.value.split(';').map(cmd => cmd.trim().split(' ').at(0));
return infoCmds.includes(definitionSymbol.name);
}
export function isBindCall(definitionSymbol: FishSymbol, node: SyntaxNode): boolean {
if (!node?.parent || isOption(node)) return false;
const parent = findParentCommand(node);
if (!parent || !isCommandWithName(parent, 'bind')) return false;
const subcommands = parent.children.slice(2).filter(c => !isOption(c));
if (!subcommands.some(c => c.equals(node))) return false;
const cmds = extractCommands(node);
return cmds.some(cmd => cmd === definitionSymbol.name);
}
export function isCompleteConditionCall(definitionSymbol: FishSymbol, node: SyntaxNode): boolean {
if (isOption(node) || !node.isNamed || isProgram(node)) return false; // skip options
if (!node.parent || !isCommandWithName(node.parent, 'complete')) return false;
if (!node?.previousSibling || !isMatchingOption(node?.previousSibling, Option.fromRaw('-n', '--condition'))) return false;
const cmds = extractCommands(node);
logger.debug(`Extracted commands from complete condition node: ${cmds}`);
return !!cmds.some(cmd => cmd.trim() === definitionSymbol.name);
}
export function isWrappedCall(definitionSymbol: FishSymbol, node: SyntaxNode): boolean {
if (!node?.parent || !findParentFunction(node)) return false;
if (node.previousNamedSibling && isMatchingOption(node.previousNamedSibling, Option.fromRaw('-w', '--wraps'))) {
const cmds = extractCommands(node);
logger.debug(`Extracted commands from wrapped call node: ${cmds}`);
return cmds.some(cmd => cmd.trim() === definitionSymbol.name);
}
if (isMatchingOptionOrOptionValue(node, Option.fromRaw('-w', '--wraps'))) {
logger.warning(`Node ${node.text} is a wrapped call for symbol ${definitionSymbol.name}`);
const cmds = extractCommands(node);
logger.debug(`Extracted commands from wrapped call node: ${cmds}`);
return cmds.some(cmd => cmd.trim() === definitionSymbol.name);
}
return false;
}
export function isAnyNestedCommand(definitionSymbol: FishSymbol, node: SyntaxNode): boolean {
return isAliasValueNode(definitionSymbol, node)
|| isBindCall(definitionSymbol, node)
|| isCompleteConditionCall(definitionSymbol, node);
}
}
/**
* Checks if a symbol will only include references local to the current document
*
* If a symbol is global, or it might be referenced in other documents (i.e., `argparse`)
* then it is not considered local to the document.
*
* @param symbol the symbol to check
* @return true if the symbol's references can only be local to the document, false otherwise
*/
function isSymbolLocalToDocument(symbol: FishSymbol): boolean {
if (symbol.isGlobal()) return false;
if (symbol.isLocal() && symbol.isArgparse()) {
const parent = symbol.parent;
// argparse flags that are inside a global function might have completions,
// so we don't consider them local to the document
if (parent && parent.isGlobal()) return false;
}
if (symbol.document.isAutoloaded()) {
if (symbol.isFunction() || symbol.hasEventHook()) {
// functions and event hooks that are autoloaded are considered global
return false;
}
if (symbol.isEvent()) {
return false; // global event hooks are not local to the document
}
}
// symbols that are not explicitly defined as global, will reach this point
// thus, we consider them local to the document
return true;
}
/**
* Extracts the precise range of a command reference within an alias definition value
* Only matches commands in command position, not as arguments
*/
function extractCommandRangeFromAliasValue(node: SyntaxNode, commandName: string): Range | null {
const text = node.text;
let searchText = text;
let baseOffset = 0;
// Handle different alias value formats
if (text.includes('=')) {
// Format: name=value
const equalsIndex = text.indexOf('=');
searchText = text.substring(equalsIndex + 1);
baseOffset = equalsIndex + 1;
}
// Remove surrounding quotes if present
if (searchText.startsWith('"') && searchText.endsWith('"') ||
searchText.startsWith("'") && searchText.endsWith("'")) {
searchText = searchText.slice(1, -1);
baseOffset += 1;
}
// Find command positions using shell command structure analysis
const commandMatches = findCommandPositions(searchText, commandName);
if (commandMatches.length === 0) return null;
// For now, return the first command match (you could return all if needed)
const firstMatch = commandMatches[0];
if (!firstMatch) return null;
const startOffset = baseOffset + firstMatch.start;
const endOffset = startOffset + commandName.length;
return Range.create(
node.startPosition.row,
node.startPosition.column + startOffset,
node.startPosition.row,
node.startPosition.column + endOffset,
);
}
/**
* Finds positions where a command name appears as an actual command (not as an argument)
*/
function findCommandPositions(shellCode: string, commandName: string): Array<{ start: number; end: number; }> {
const matches: Array<{ start: number; end: number; }> = [];
// Split by command separators: ; && || & | (pipes and logical operators)
const commandSeparators = /([;&|]+|\s*&&\s*|\s*\|\|\s*)/;
const parts = shellCode.split(commandSeparators);
let currentOffset = 0;
for (const part of parts) {
if (!part || commandSeparators.test(part)) {
// This is a separator, skip it
currentOffset += part.length;
continue;
}
// Clean up whitespace and find the first word (command)
const trimmedPart = part.trim();
const partStartOffset = currentOffset + part.indexOf(trimmedPart);
if (trimmedPart) {
// Extract the first word as the command
const firstWordMatch = trimmedPart.match(/^([^\s]+)/);
if (firstWordMatch) {
const firstWord = firstWordMatch[1];
if (firstWord === commandName) {
matches.push({
start: partStartOffset,
end: partStartOffset + commandName.length,
});
}
}
}
currentOffset += part.length;
}
return matches;
}
/**
* Optimized version of getChildNodes that pre-filters by text content
* This significantly reduces the number of nodes we need to check
*/
function* getChildNodesOptimized(symbol: FishSymbol, doc: LspDocument): Generator {
const root = analyzer.getRootNode(doc.uri);
if (!root) return;
const localSymbols = analyzer.getFlatDocumentSymbols(doc.uri)
.filter(s => {
if (s.uri === doc.uri) return false;
if (s.isFunction() && s.isLocal() && s.name === symbol.name && symbol.isFunction()) {
return !s.equals(symbol);
}
return s.name === symbol.name
&& s.kind === symbol.kind
&& s.isLocal()
&& !symbol.equalDefinition(s);
});
const skipNodes = localSymbols.map(s => s.parent?.node).filter(n => n !== undefined) as SyntaxNode[];
const isPotentialMatch = (current: SyntaxNode) => {
if (symbol.isArgparse()
&& (isOption(current) || current.text === symbol.name || current.text === symbol.argparseFlagName)
) {
return true;
} else if (symbol.name === current.text) {
return true;
} else if (isString(current)) {
return true;
}
if (symbol.isFunction()) {
return symbol.name === current.text
|| isCommandName(current)
|| current.type === 'word'
|| current.isNamed;
}
return false;
};
const queue: SyntaxNode[] = [root];
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
if (
skipNodes && skipNodes.some(s =>
containsNode(s, current) || s.equals(current) && !isProgram(current),
)) {
continue;
}
if (isPotentialMatch(current)) {
yield current;
}
// Add children to queue for processing
if (current.children.length > 0) {
queue.unshift(...current.children);
}
}
}
/**
* Returns a list of documents to search for references based on the options provided.
*
* @param document the document to search in
* @param logCallback the logging callback function
* @param opts the options for searching references
* @return an array of documents to search for references
*/
function getDocumentsToSearch(
document: LspDocument,
logCallback: ReturnType,
opts: ReferenceOptions,
): LspDocument[] {
let documentsToSearch: LspDocument[] = [];
if (opts.localOnly) {
documentsToSearch.push(document);
} else if (opts.allWorkspaces) {
workspaceManager.all.forEach((ws: Workspace) => {
documentsToSearch.push(...ws.allDocuments());
});
} else {
// default to using the current workspace
let currentWorkspace = workspaceManager.current;
if (!currentWorkspace) {
currentWorkspace = workspaceManager.findContainingWorkspace(document.uri) || undefined;
if (!currentWorkspace) {
logCallback(`No current workspace found for document ${document.uri}`, 'warning');
return [document];
}
}
currentWorkspace?.allDocuments().forEach((doc: LspDocument) => {
documentsToSearch.push(doc);
});
}
// filter out documents that don't match the specified file types
if (opts.onlyInFiles && opts.onlyInFiles.length > 0) {
documentsToSearch = documentsToSearch.filter(doc => {
const fileType = doc.getAutoloadType();
if (!fileType) return false;
return opts.onlyInFiles!.includes(fileType);
});
}
return documentsToSearch;
}
/**
* Callback wrapper function for logging the getReferences function,
* so that the parent function doesn't have to handle logging directly.
*
* Forwards the getReferences(params) to this function.
*
* Calls the logger.info/debug/warning/error methods with the request and params.
*/
function logWrapper(
document: LspDocument,
position: Position,
opts: ReferenceOptions,
) {
const posStr = `{line: ${position.line}, character: ${position.character}}`;
const requestMsg = `getReferencesNew(params) -> ${new Date().toISOString()}`;
const params = {
uri: uriToReadablePath(document.uri),
position: posStr,
opts: opts,
};
const startTime = performance.now();
return function(message: string, level: 'info' | 'debug' | 'warning' | 'error' = 'info') {
if (!opts.loggingEnabled) return; // If logging is disabled
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2); // Convert to seconds with 2 decimal places
const logObj: {
request: string;
params: typeof params;
message: string;
// duration?: string;
} = {
request: requestMsg,
params,
message,
// duration: opts.logPerformance ? `duration: ${duration} seconds` : undefined,
};
switch (level) {
case 'info':
logger.info(logObj, duration);
break;
case 'debug':
logger.debug(logObj, duration);
break;
case 'warning':
logger.warning(logObj, duration);
break;
case 'error':
logger.error({
...logObj,
message: `Error: ${message}`,
duration,
});
break;
default:
logger.warning({
...logObj,
message: `Unknown log level: ${level}. Original message: ${message}`,
duration,
});
break;
}
logger.debug(`DURATION: ${duration}`, { uri: uriToReadablePath(document.uri), position: posStr });
};
}
/**
* Sorts the references based on their proximity to the definition symbol,
* Sorting by:
* 1. Definition Symbol URI (and local references)
* 2. Go to implementation URI (functions/ <-> completions/)
* 3. References Grouped by order of URI seen in Workspace Search
* 4. Position (Top to Bottom, Left to Right)
*/
const locationSorter = (defSymbol: FishSymbol) => {
const getUriPriority = (defSymbol: FishSymbol) => {
return (uri: DocumentUri) => {
let basePriority = 10; // default
if (defSymbol.isArgparse()) {
if (uri === defSymbol.uri) basePriority = 100;
else if (uri.includes('completions/')) basePriority = 50;
} else if (defSymbol.isFunction()) {
if (uri === defSymbol.uri) basePriority = 100;
else if (uri.includes('completions/')) basePriority = 50;
} else if (defSymbol.isVariable()) {
if (uri === defSymbol.uri) basePriority = 100;
}
// Add a small fraction based on URI string for consistent ordering
const uriHash = uri.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return basePriority + uriHash % 1000 / 10000; // keeps string order as decimal
};
};
const uriPriority = getUriPriority(defSymbol);
return function(a: Location, b: Location) {
const aUriPriority = uriPriority(a.uri);
const bUriPriority = uriPriority(b.uri);
if (aUriPriority !== bUriPriority) {
return bUriPriority - aUriPriority; // higher priority first
}
// same URI, sort by position
if (a.range.start.line !== b.range.start.line) {
return a.range.start.line - b.range.start.line;
}
return a.range.start.character - b.range.start.character;
};
};
export const getFilteredLocalSymbols = (definitionSymbol: FishSymbol, doc: LspDocument) => {
if (definitionSymbol.isVariable() && !definitionSymbol.isArgparse()) {
// if the symbol is a variable, we only want to find references in the current document
return analyzer.getFlatDocumentSymbols(doc.uri)
.filter(
s => s.isLocal()
&& !s.equals(definitionSymbol)
&& !definitionSymbol.equalScopes(s)
// && !s.parent?.equals(definitionSymbol?.parent || definitionSymbol)
&& s.name === definitionSymbol.name
&& s.kind === definitionSymbol.kind,
);
}
if (doc.uri === definitionSymbol.uri) return [];
return analyzer.getFlatDocumentSymbols(doc.uri)
.filter(s =>
s.isLocal()
&& s.name === definitionSymbol.name
&& s.kind === definitionSymbol.kind
&& !s.equals(definitionSymbol),
);
};
================================================
FILE: src/renames.ts
================================================
import { getReferences } from './references';
import { analyzer, Analyzer } from './analyze';
import { Position, Range } from 'vscode-languageserver';
import { LspDocument } from './document';
import { FishSymbol } from './parsing/symbol';
import { logger } from './logger';
export type FishRenameLocationType = 'variable' | 'function' | 'command' | 'argparse' | 'flag';
export interface FishRenameLocation {
uri: string;
range: Range;
type: FishRenameLocationType;
newText: string;
}
export function getRenames(
doc: LspDocument,
position: Position,
newText: string,
): FishRenameLocation[] {
const symbol = analyzer.getDefinition(doc, position);
if (!symbol || !newText) return [];
if (!canRenameWithNewText(analyzer, doc, position, newText)) return [];
newText = fixNewText(symbol, position, newText);
const locs = getReferences(doc, position);
return locs.map(loc => {
const locationText = analyzer.getTextAtLocation(loc);
let replaceText = newText || locationText;
if (locationText.startsWith('_flag_') && symbol.fishKind === 'ARGPARSE') {
loc.range.start.character += '_flag_'.length;
if (newText?.includes('-')) {
replaceText = newText.replace(/-/g, '_');
}
}
if (locationText.includes('=') && symbol.fishKind === 'ARGPARSE') {
loc.range.end.character = loc.range.start.character + locationText.indexOf('=');
}
return {
uri: loc.uri,
range: loc.range,
type: symbol.fishKind as FishRenameLocationType,
newText: replaceText,
};
});
}
/**
* Currently for rename requests that are for an argparse FishSymbol,
* that are from a request that is not on the symbol definition.
* ```fish
* function foo
* argparse 'values-with=?' -- $argv
* or return
*
* if set -ql _flag_values_with
* end
* end
*
* foo --values-with
* ```
*
* Case 1.) the rename request is on `_flag_values_with`, we need to remove the
* leading `_flag_` from the newText
*
* Case 2.) the rename request is on `--values-with`, we need to remove the leading `--`
*/
function fixNewText(symbol: FishSymbol, position: Position, newText: string) {
// EDGE CASE 1: rename on a flag usage: _flag_values_with
// would still work if the rename request is under `argparse 'values-with=?'`
// So, we need a check for if the newText starts with _flag_, then we trim off the _flag_
if (symbol.fishKind === 'ARGPARSE' && !symbol.containsPosition(position) && newText?.startsWith('_flag_')) {
return newText.replace(/^_flag_/g, '').replace(/_/g, '-');
}
// EDGE CASE 2: rename on a flag usage: `--values-with`
// would still work if the rename request is under `argparse 'values-with=?'`
// So, we need to check for leading '-', and remove them
if (symbol.fishKind === 'ARGPARSE' && !symbol.containsPosition(position) && newText?.startsWith('-')) {
return newText.replace(/^-{1,2}/, '');
}
return newText;
}
function canRenameWithNewText(analyzer: Analyzer, doc: LspDocument, position: Position, newText: string): boolean {
const isShort = (str: string) => {
if (str.startsWith('--')) return false;
if (str.startsWith('-')) return true;
return false;
};
const isLong = (str: string) => {
if (str.startsWith('--')) return true;
if (str.startsWith('-')) return false;
return false;
};
const isEqualFlags = (str1: string, str2: string) => {
if (isShort(str1) && !isShort(str2)) {
return false;
}
if (isLong(str1) && !isLong(str2)) {
return false;
}
return true;
};
const isFlag = (str: string) => {
return str.startsWith('-');
};
const oldText = analyzer.wordAtPoint(doc.uri, position.line, position.character);
logger.log({
oldText,
newText,
});
if (oldText && isFlag(oldText) && !isEqualFlags(oldText, newText)) return false;
return true;
}
================================================
FILE: src/selection-range.ts
================================================
import { SelectionRange, Position } from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { LspDocument } from './document';
import { analyzer } from './analyze';
import { isPositionInNode, getRange } from './utils/tree-sitter';
/**
* Provides smart selection ranges for fish shell code.
*
* This allows users to incrementally expand their selection based on the
* syntactic structure of the code (e.g., word → argument → command → block → function).
*
* The selection hierarchy follows the tree-sitter parse tree structure.
*/
/**
* Find the smallest node containing the position
*/
function findSmallestNode(node: SyntaxNode, position: Position): SyntaxNode | null {
if (!isPositionInNode(position, node)) {
return null;
}
// Try to find a smaller child node
for (const child of node.namedChildren) {
const result = findSmallestNode(child, position);
if (result) {
return result;
}
}
// Skip whitespace and newline nodes for selection
if (node.type === '\\n' || node.type === ' ') {
return node.parent || node;
}
return node;
}
/**
* Determine if a node type should be included in the selection hierarchy
*/
function shouldIncludeInHierarchy(node: SyntaxNode): boolean {
// Skip these node types as they don't provide meaningful selection boundaries
const skipTypes = ['\\n', ' ', '(', ')', '[', ']', '{', '}', '"', "'", '$'];
return !skipTypes.includes(node.type);
}
/**
* Build the selection range hierarchy from a node upwards
*/
function buildSelectionHierarchy(node: SyntaxNode): SelectionRange {
const range = getRange(node);
// Find the next meaningful parent in the hierarchy
let parent = node.parent;
let parentRange: SelectionRange | undefined = undefined;
while (parent) {
// Only include meaningful nodes in the hierarchy
if (shouldIncludeInHierarchy(parent)) {
// Avoid creating redundant parent selections with identical ranges
const parentLspRange = getRange(parent);
if (
parentLspRange.start.line !== range.start.line ||
parentLspRange.start.character !== range.start.character ||
parentLspRange.end.line !== range.end.line ||
parentLspRange.end.character !== range.end.character
) {
parentRange = buildSelectionHierarchy(parent);
break;
}
}
parent = parent.parent;
}
return {
range,
parent: parentRange,
};
}
/**
* Get selection ranges for the given positions in the document
*/
export function getSelectionRanges(
document: LspDocument,
positions: Position[],
): SelectionRange[] {
const result: SelectionRange[] = [];
const rootNode = analyzer.getRootNode(document.uri);
if (!rootNode) {
return result;
}
for (const position of positions) {
const node = findSmallestNode(rootNode, position);
if (node) {
result.push(buildSelectionHierarchy(node));
}
}
return result;
}
================================================
FILE: src/semantic-tokens.ts
================================================
import { SyntaxNode } from 'web-tree-sitter';
import { analyzer, EnsuredAnalyzeDocument } from './analyze';
import * as LSP from 'vscode-languageserver';
import { logger } from './logger';
import { FishSymbol } from './parsing/symbol';
import { flattenNested } from './utils/flatten';
import { calculateModifiersMask, createTokensFromMatches, getTextMatchPositions, getVariableModifiers, SemanticToken, SemanticTokenModifier, FishSemanticTokens } from './utils/semantics';
import { isCommandName, isCommandWithName, isEndStdinCharacter, isShebang, isVariableExpansion } from './utils/node-types';
import { LspDocument } from './document';
import { BuiltInList } from './utils/builtins';
import { isDiagnosticComment } from './diagnostics/comments-handler';
import { getRange, isNodeWithinRange } from './utils/tree-sitter';
import { getSymbolModifiers } from './parsing/symbol-modifiers';
import { PrebuiltDocumentationMap } from './utils/snippets';
import { AutoloadedPathVariables } from './utils/process-env';
/**
* We only want to return the semantic tokens that clients aren't highlighting, since
* they likely don't use analysis to determine which arguments/words in a script are
* defining symbols.
*
* Cases which we want to return semantic tokens for:
* - FishSymbol definitions and references:
* - Function definitions (so that function names can be highlighted differently)
* - Function calls (so that function calls can be highlighted differently)
* - Variable definitions (so that variable names can be highlighted differently)
* - Variable references (so that variable references can be highlighted differently)
* - Special tokens: `--`
* - Special comments:
* - Disable diagnostics comments: `# @fish-lsp-disable ...`
* - Shebangs: `#!/usr/bin/env fish`
*
* We really don't care about modifier support at this time. Since we've already worked
* pretty significantly to resolve these correctly directly from a FishSymbol, we can
* determine what/which modifiers to include once more language clients clarify
* how they would like to handle them.
*/
/**
* Convert modifier names to bitmask, filtering out unsupported modifiers.
*/
function modifiersToBitmask(modifiers: SemanticTokenModifier[]): number {
return modifiers.reduce((mask, mod) => {
const idx = FishSemanticTokens.legend.tokenModifiers.indexOf(mod);
return idx >= 0 ? mask | 1 << idx : mask;
}, 0);
}
function symbolToSemanticToken(symbol: FishSymbol): SemanticToken | null {
if (symbol.isFunction()) {
// Get modifiers from the symbol using getSymbolModifiers
// This filters to only supported modifiers (no autoloaded, not-autoloaded, script, etc.)
const mods = getSymbolModifiers(symbol);
// Highlight alias names as functions (the alias name itself, not the 'alias' keyword)
// The 'alias' keyword is handled by the keyword handler
return {
line: symbol.selectionRange.start.line,
startChar: symbol.selectionRange.start.character,
length: symbol.selectionRange.end.character - symbol.selectionRange.start.character,
tokenType: FishSemanticTokens.types.function,
tokenModifiers: modifiersToBitmask(mods),
};
} else if (symbol.isVariable()) {
// Use selectionRange which excludes the $ prefix
const startChar = symbol.selectionRange.start.character;
const length = symbol.selectionRange.end.character - startChar;
// Skip if the length is invalid (could be shebang or other non-variable symbol)
if (length <= 0) {
return null;
}
// Get modifiers from the symbol
const mods = getSymbolModifiers(symbol);
return {
line: symbol.selectionRange.start.line,
startChar,
length,
tokenType: FishSemanticTokens.types.variable,
tokenModifiers: modifiersToBitmask(mods),
};
}
return null;
}
/**
* Structural keywords that modify control flow or define blocks.
* These are highlighted as keywords, not functions.
*/
const STRUCTURAL_KEYWORDS = [
'function', 'end',
'if', 'else',
'for', 'while', 'in',
'switch', 'case',
'and', 'or', 'not',
'break', 'continue', 'return', 'exit',
'begin',
'alias',
];
/**
* Check if a node is a structural keyword.
* These are block-modifying keywords like `if`, `for`, `function`, etc.
*/
const isStructuralKeyword = (n: SyntaxNode): boolean => {
// Direct node type match (e.g., 'function', 'end', 'in')
if (STRUCTURAL_KEYWORDS.includes(n.type)) {
return true;
}
// For command nodes, check the command name
if (n.type === 'command' || isCommandName(n)) {
const cmdName = n.type === 'command' && n.firstNamedChild
? n.firstNamedChild.text
: n.text;
return STRUCTURAL_KEYWORDS.includes(cmdName);
}
return false;
};
/**
* Check if a command is a builtin function (not a structural keyword).
* These are commands from `builtin -n` that aren't structural keywords.
* Examples: echo, set, path, source, fish_key_reader
*/
const isBuiltinFunction = (n: SyntaxNode): boolean => {
if (n.type !== 'command') return false;
const cmdName = n.firstNamedChild;
if (!cmdName) return false;
// Must be in builtin list and NOT a structural keyword
return BuiltInList.includes(cmdName.text) && !STRUCTURAL_KEYWORDS.includes(cmdName.text);
};
/**
* Check if a command is a user-defined or fish-shipped function call.
* Excludes structural keywords and builtin functions.
*/
const isUserFunction = (n: SyntaxNode): boolean => {
if (n.type !== 'command') return false;
if (isStructuralKeyword(n)) return false;
if (isBuiltinFunction(n)) return false;
if (isCommandWithName(n, '[')) return false; // Special handling for bracket test
return true;
};
const isBracketTestCommand = (n: SyntaxNode) => isCommandWithName(n, '[');
type isNodeMatch = (node: SyntaxNode) => boolean;
type nodeToTokenFunc = (node: SyntaxNode, ctx: SemanticTokenContext) => void;
type NodeToToken = [isNodeMatch, nodeToTokenFunc];
const nodeToTokenHandler: NodeToToken[] = [
// `#!/usr/bin/env fish`
[isShebang, (n, ctx) => {
ctx.tokens.push(
SemanticToken.fromNode(n, FishSemanticTokens.types.decorator, 0),
);
}],
// `# @fish-lsp-disable ...` - only highlight the @fish-lsp-* part
[isDiagnosticComment, (n, ctx) => {
ctx.tokens.push(
...createTokensFromMatches(
getTextMatchPositions(n, /@fish-lsp-(enable|disable)(?:-next-line)?/g),
FishSemanticTokens.types.keyword,
0,
),
);
}],
// Special handling for `[` test command - highlight opening [ and closing ]
// Example: [ -f /tmp/foo.fish ] or [ -n "string" ]
// This ensures we don't confuse it with array indexing like $arr[0]
[isBracketTestCommand, (n, ctx) => {
const firstChild = n.firstNamedChild;
if (firstChild && firstChild.type === 'word') {
// Find the opening [ token within the word node
const openBracket = firstChild.firstChild;
if (openBracket && openBracket.type === '[') {
ctx.tokens.push(
SemanticToken.fromNode(openBracket, FishSemanticTokens.types.function, calculateModifiersMask('defaultLibrary')),
);
}
}
// Find the closing ] in the last argument
const lastChild = n.lastNamedChild;
if (lastChild && lastChild.type === 'word') {
const closeBracket = lastChild.firstChild;
if (closeBracket && closeBracket.type === ']') {
ctx.tokens.push(
SemanticToken.fromNode(closeBracket, FishSemanticTokens.types.function, calculateModifiersMask('defaultLibrary')),
);
}
}
}],
// Structural keywords: `if`, `for`, `function`, `alias`, etc.
[isStructuralKeyword, (n, ctx) => {
// For command nodes, only highlight the first child (the keyword itself)
// For non-command nodes (like standalone keywords), highlight the whole node
const targetNode = n.type === 'command' && n.firstNamedChild ? n.firstNamedChild : n;
ctx.tokens.push(
SemanticToken.fromNode(targetNode, FishSemanticTokens.types.keyword, 0),
);
}],
// Builtin functions: `echo`, `set`, `path`, `source`, etc.
// These are commands from `builtin -n` but not structural keywords
//
// As of PR #133, builtin functions are now treated exactly the same
// as defaultLibrary functions which include shared function definitions
// like: `__fish_use_subcommand` or other `$__fish_data_dir/functions/*.fish` files
[isBuiltinFunction, (n, ctx) => {
const cmd = n.firstNamedChild;
if (!cmd) return;
ctx.tokens.push(
SemanticToken.fromNode(cmd, FishSemanticTokens.types.function, calculateModifiersMask('defaultLibrary')),
);
}],
// User-defined or fish-shipped function calls
[isUserFunction, (n, ctx) => {
const cmd = n.firstNamedChild;
if (!cmd) return;
// Look up the function symbol to get its modifiers
let modifiers = 0;
const localSymbols = analyzer.cache.getFlatDocumentSymbols(ctx.document.uri);
const funcSymbol = localSymbols.find(s => s.isFunction() && s.name === cmd.text);
if (funcSymbol) {
// Use getSymbolModifiers and filter to supported modifiers
const mods = getSymbolModifiers(funcSymbol).filter(m =>
FishSemanticTokens.legend.tokenModifiers.includes(m as any),
);
modifiers = modifiersToBitmask(mods);
} else {
// Check global symbols
const globalSymbols = analyzer.globalSymbols.find(cmd.text);
const globalFunc = globalSymbols.find(s => s.isFunction());
if (globalFunc) {
const mods = getSymbolModifiers(globalFunc).filter(m =>
FishSemanticTokens.legend.tokenModifiers.includes(m as any),
);
modifiers = modifiersToBitmask(mods);
} else {
// Check if it's a fish-shipped function
const fishShippedDocs = PrebuiltDocumentationMap.getByName(cmd.text);
const isFishShipped = fishShippedDocs.some(doc => doc.type === 'function');
if (isFishShipped) {
modifiers = calculateModifiersMask('defaultLibrary');
} else {
// Last resort: check if this could be an autoloaded function
// by searching fish_function_path directories
const autoloadedPath = AutoloadedPathVariables.findAutoloadedFunctionPath(cmd.text);
if (autoloadedPath) {
modifiers = calculateModifiersMask('global');
}
}
}
}
ctx.tokens.push(
SemanticToken.fromNode(cmd, FishSemanticTokens.types.function, modifiers),
);
}],
// variable expansions
[isVariableExpansion, (n, ctx) => {
const variableName = n.text.replace(/^\$/, '');
const modifiers = getVariableModifiers(variableName, ctx.document.uri);
ctx.tokens.push(
...createTokensFromMatches(
getTextMatchPositions(n, /[^$]+/),
FishSemanticTokens.types.variable,
modifiers,
),
);
}],
// special end-of-stdin character `--`
[isEndStdinCharacter, (n, ctx) => {
ctx.tokens.push(
SemanticToken.fromNode(n, FishSemanticTokens.types.operator, 0),
);
}],
// number literals: integers and floats
[(n) => n.type === 'integer' || n.type === 'float', (n, ctx) => {
ctx.tokens.push(
SemanticToken.fromNode(n, FishSemanticTokens.types.number, 0),
);
}],
];
export function getSemanticTokensSimplest(analyzedDoc: EnsuredAnalyzeDocument, range: LSP.Range) {
const nodes = analyzer.getNodes(analyzedDoc.document.uri);
const symbols = flattenNested(...analyzedDoc.documentSymbols);
// create hashmap of semantic tokens? or something for O(1)ish lookups so that other
// types of tokens that we create can immediately be skipped if they already exist.
const ctx: SemanticTokenContext = SemanticTokenContext.create({ document: analyzedDoc.document });
for (const symbol of symbols) {
if (!symbol.focusedNode) continue;
if (range && !isNodeWithinRange(symbol.focusedNode, range)) continue;
const token = symbolToSemanticToken(symbol);
if (token) {
ctx.add(token);
}
}
// now we're just about done!
for (const node of nodes) {
// out of range
if (!isNodeWithinRange(node, range)) {
continue;
}
// filter out dupes
if (ctx.hasNode(node)) {
continue;
}
// ^^^ consider avoiding this till the end to limit runtime complexity? ^^^
nodeToTokenHandler.find(([isMatch, toToken]) => {
if (isMatch(node)) {
toToken(node, ctx);
return true; // Stop searching once we find a match
}
return false;
});
}
return ctx.build();
}
const hashToken = (token: SemanticToken): string => {
return `${token.line}:${token.startChar}:${token.tokenType}`;
};
class SemanticTokenContext {
private constructor(
public document: LspDocument,
public tokens: SemanticToken[] = [],
private seenTokens: Map = new Map(),
) { }
public static create({ document, tokens = [] }: {
document: LspDocument;
tokens?: SemanticToken[];
}): SemanticTokenContext {
return new SemanticTokenContext(document, tokens);
}
public has(token: SemanticToken): boolean {
return this.seenTokens.has(hashToken(token));
}
public hasNode(node: SyntaxNode): boolean {
const token = SemanticToken.fromNode(node, 0, 0);
return this.seenTokens.has(hashToken(token));
}
public add(...tokens: SemanticToken[]): void {
for (const token of tokens) {
if (!this.seenTokens.has(hashToken(token))) {
this.seenTokens.set(hashToken(token), token);
this.tokens.push(token);
}
}
}
public get size(): number {
return this.tokens.length;
}
public getTokens(): SemanticToken[] {
return this.tokens;
}
public clear(): void {
this.tokens.length = 0;
this.seenTokens.clear();
this.tokens = [];
}
public show(): void {
logger.log({
document: this.document?.uri,
size: this.size,
tokens: this.tokens,
seenTokens: Array.from(this.seenTokens.values()),
});
}
public build() {
const builder = new LSP.SemanticTokensBuilder();
// Sort tokens by position
const sortedTokens = [...this.tokens].sort((a, b) => {
if (a.line !== b.line) return a.line - b.line;
if (a.startChar !== b.startChar) return a.startChar - b.startChar;
return a.length - b.length;
});
// Remove duplicates and overlaps (keep first occurrence)
const uniqueTokens: SemanticToken[] = [];
let lastEnd = { line: -1, char: -1 };
for (const token of sortedTokens) {
const tokenEnd = token.startChar + token.length;
// Skip if this token overlaps with the previous one on the same line
if (token.line === lastEnd.line && token.startChar < lastEnd.char) {
continue;
}
uniqueTokens.push(token);
lastEnd = { line: token.line, char: tokenEnd };
}
// Push tokens to builder
for (const token of uniqueTokens) {
builder.push(
token.line,
token.startChar,
token.length,
token.tokenType,
token.tokenModifiers,
);
}
return builder.build();
}
}
type SemanticTokensParams = LSP.SemanticTokensParams | LSP.SemanticTokensRangeParams;
/**
* Type guards for distinguishing between full and range semantic token requests.
*/
export namespace Semantics {
export const params = {
isFull(params: SemanticTokensParams): params is LSP.SemanticTokensParams {
return (
(params as LSP.SemanticTokensParams).textDocument !== undefined &&
(params as LSP.SemanticTokensRangeParams).range === undefined
);
},
isRange(params: SemanticTokensParams): params is LSP.SemanticTokensRangeParams {
return (params as LSP.SemanticTokensRangeParams).range !== undefined;
},
};
export const response = {
empty: (): LSP.SemanticTokens => ({ data: [] }),
};
}
/**
* Main handler for semantic token requests.
*/
export function semanticTokenHandler(params: SemanticTokensParams): LSP.SemanticTokens {
// retrieve the analyzed document for the requested URI
const cachedDoc = analyzer.cache.getDocument(params.textDocument.uri)?.ensureParsed();
if (!cachedDoc) {
logger.warning(`No analyzed document found for URI: ${params.textDocument.uri}`);
return Semantics.response.empty();
}
/* handle our 2 use cases */
if (Semantics.params.isRange(params)) {
return getSemanticTokensSimplest(cachedDoc, params.range);
} else if (Semantics.params.isFull(params)) {
return getSemanticTokensSimplest(cachedDoc, getRange(cachedDoc.root));
}
return Semantics.response.empty();
}
================================================
FILE: src/server.ts
================================================
// Import polyfills for Node.js 18 compatibility
import './utils/polyfills';
// Initialize virtual filesystem (must be before any embedded asset usage)
import './virtual-fs';
import { SyntaxNode } from 'web-tree-sitter';
import { analyzer, Analyzer } from './analyze';
import { InitializeParams, CompletionParams, Connection, CompletionList, CompletionItem, MarkupContent, DocumentSymbolParams, DefinitionParams, Location, ReferenceParams, DocumentSymbol, InitializeResult, HoverParams, Hover, RenameParams, TextDocumentPositionParams, TextDocumentIdentifier, WorkspaceEdit, TextEdit, DocumentFormattingParams, DocumentRangeFormattingParams, FoldingRangeParams, FoldingRange, InlayHintParams, MarkupKind, WorkspaceSymbolParams, WorkspaceSymbol, SymbolKind, CompletionTriggerKind, SignatureHelpParams, SignatureHelp, ImplementationParams, CodeLensParams, CodeLens, WorkspaceFoldersChangeEvent, SelectionRangeParams, SelectionRange } from 'vscode-languageserver';
import * as LSP from 'vscode-languageserver';
import { LspDocument, documents, rangeOverlapsLineSpan } from './document';
import { formatDocumentWithIndentComments, formatDocumentContent } from './formatting';
import { logger, now } from './logger';
import { connection, setExternalConnection } from './utils/startup';
import { formatTextWithIndents, symbolKindsFromNode, uriToPath } from './utils/translation';
import { getChildNodes } from './utils/tree-sitter';
import { getVariableExpansionDocs, handleHover } from './hover';
import { DocumentationCache, initializeDocumentationCache } from './utils/documentation-cache';
import { getWorkspacePathsFromInitializationParams, initializeDefaultFishWorkspaces } from './utils/workspace';
import { workspaceManager } from './utils/workspace-manager';
import { formatFishSymbolTree, filterLastPerScopeSymbol, FishSymbol } from './parsing/symbol';
import { CompletionPager, initializeCompletionPager, isInVariableExpansionContext, SetupData } from './utils/completion/pager';
import { FishCompletionItem } from './utils/completion/types';
import { getDocumentationResolver } from './utils/completion/documentation';
import { FishCompletionList } from './utils/completion/list';
import { PrebuiltDocumentationMap, getPrebuiltDocUrl } from './utils/snippets';
import { findParent, findParentCommand, isAliasDefinitionName, isBraceExpansion, isCommand, isCommandName, isConcatenatedValue, isConcatenation, isEndStdinCharacter, isOption, isPathNode, isReturnStatusNumber, isVariableDefinition } from './utils/node-types';
import { config, Config } from './config';
import { enrichToMarkdown, handleBraceExpansionHover, handleEndStdinHover, handleSourceArgumentHover } from './documentation';
import { findActiveParameterStringRegex, getAliasedCompletionItemSignature, getDefaultSignatures, getFunctionSignatureHelp, isRegexStringSignature } from './signature';
import { CompletionItemMap } from './utils/completion/startup-cache';
import { getDocumentHighlights } from './document-highlight';
import { semanticTokenHandler } from './semantic-tokens';
import { buildCommentCompletions } from './utils/completion/comment-completions';
import { codeActionHandlers } from './code-actions/code-action-handler';
import { createExecuteCommandHandler } from './command';
import { getAllInlayHints } from './inlay-hints';
import { setupProcessEnvExecFile } from './utils/process-env';
import { flattenNested } from './utils/flatten';
import { isArgparseVariableDefinitionName } from './parsing/argparse';
import { isSourceCommandArgumentName } from './parsing/source';
import { getReferences } from './references';
import { getRenames } from './renames';
import { getReferenceCountCodeLenses } from './code-lens';
import { getSelectionRanges } from './selection-range';
import { PkgJson } from './utils/commander-cli-subcommands';
import { ProgressNotification } from './utils/progress-notification';
import { md } from './utils/markdown-builder';
export type SupportedFeatures = {
codeActionDisabledSupport: boolean;
};
export let server: FishServer;
/**
* The globally accessible configuration setting. Set from the client, and used by the server.
* When enabled, the analyzer will search through the current workspace, and update it's
* cache of symbols only within the current workspace. When disabled, the analyzer will have
* to search through all workspaces.
*
* Also, this setting is used to determine if the initializationResult.workspace.workspaceFolders
* should be enabled or disabled.
*/
export let hasWorkspaceFolderCapability = false;
export const enableWorkspaceFolderSupport = () => {
hasWorkspaceFolderCapability = true;
};
export let currentDocument: LspDocument | null = null;
export let cachedDocumentation: DocumentationCache;
export let cachedCompletionMap: CompletionItemMap;
export default class FishServer {
/**
* How a client importing the server as a module would connect to a new server instance
*
* After a connection is created by the client this method will setup the server
* to allow the connection to communicate between the client and server.
* ___
*
* @example
* ```ts
* import FishServer from 'fish-lsp';
* import {
* createConnection,
* InitializeParams,
* InitializeResult,
* ProposedFeatures,
* } from 'vscode-languageserver/node';
*
* const connection = createConnection(ProposedFeatures.all)
*
* connection.onInitialize(
* async (params: InitializeParams): Promise => {
* const { initializeResult } = await FishServer.create(connection, params);
*
* return initializeResult;
* },
* );
* connection.listen();
* ```
* ___
*
* @param connection The LSP.Connection to use
* @param params The initialization parameters from the client
* @returns The created FishServer instance and the initialization result
*/
public static async create(
connection: Connection,
params: InitializeParams,
): Promise<{ server: FishServer; initializeResult: InitializeResult; }> {
setExternalConnection(connection);
await setupProcessEnvExecFile();
const capabilities = params.capabilities;
const initializeResult = Config.initialize(params, connection);
logger.log({
server: 'FishServer',
rootUri: params.rootUri,
rootPath: params.rootPath,
workspaceFolders: params.workspaceFolders,
});
// set this only it it hasn't been set yet
hasWorkspaceFolderCapability = !!(
!!capabilities?.workspace && !!capabilities?.workspace.workspaceFolders
);
logger.debug('hasWorkspaceFolderCapability', hasWorkspaceFolderCapability);
const initializeUris = getWorkspacePathsFromInitializationParams(params);
logger.info('initializeUris', initializeUris);
// Run these operations in parallel rather than sequentially
const [
cache,
_workspaces,
completionsMap,
] = await Promise.all([
initializeDocumentationCache(),
initializeDefaultFishWorkspaces(...initializeUris),
CompletionItemMap.initialize(),
]);
cachedDocumentation = cache;
cachedCompletionMap = completionsMap;
await Analyzer.initialize();
const completions = await initializeCompletionPager(logger, completionsMap);
server = new FishServer(
completions,
completionsMap,
cache,
params,
);
server.register(connection);
return { server, initializeResult };
}
protected features: SupportedFeatures;
public clientSupportsShowDocument: boolean;
public backgroundAnalysisComplete: boolean;
private backgroundAnalysisInProgress: boolean;
constructor(
private completion: CompletionPager,
private completionMap: CompletionItemMap,
private documentationCache: DocumentationCache,
private initializeParams: InitializeParams,
) {
this.features = { codeActionDisabledSupport: true };
this.clientSupportsShowDocument = false;
this.backgroundAnalysisComplete = false;
this.backgroundAnalysisInProgress = false;
}
/**
* Bind the connection handlers to their corresponding methods in the
* server so that {@link FishServer.create()} initializes the server with all handlers
* enabled.
*
* The `src/config.ts` file handles dynamic enabling/disabling of these
* handlers based on client capabilities and user configuration.
*
* @see {@link Config.getResultCapabilities} for the capabilities negotiated
*
* @param connection The {@link https://github.com/microsoft/vscode-extension-samples/blob/5839b5c2336e1488ee642a037a2084f2dd3d6755/lsp-embedded-language-service/server/src/server.ts#L20|LSP.Connection} to register handlers on
* @returns void
*/
register(connection: Connection): void {
// setup callback handlers
const { onCodeActionCallback, onCodeActionResolveCallback } = codeActionHandlers();
const documentHighlightHandler = getDocumentHighlights(analyzer);
// Semantic tokens handler using simplified unified handler
// The semanticTokenHandler handles both full document and range requests internally
const commandCallback = createExecuteCommandHandler(connection);
// register the handlers
// connection.onDidOpenTextDocument(this.didOpenTextDocument.bind(this));
// connection.onDidChangeTextDocument(this.didChangeTextDocument.bind(this));
// connection.onDidCloseTextDocument(this.didCloseTextDocument.bind(this));
connection.onDidSaveTextDocument(this.didSaveTextDocument.bind(this));
connection.onCompletion(this.onCompletion.bind(this));
connection.onCompletionResolve(this.onCompletionResolve.bind(this));
connection.onDocumentSymbol(this.onDocumentSymbols.bind(this));
connection.onWorkspaceSymbol(this.onWorkspaceSymbol.bind(this));
connection.onWorkspaceSymbolResolve(this.onWorkspaceSymbolResolve.bind(this));
connection.onDefinition(this.onDefinition.bind(this));
connection.onImplementation(this.onImplementation.bind(this));
connection.onReferences(this.onReferences.bind(this));
connection.onHover(this.onHover.bind(this));
connection.onRenameRequest(this.onRename.bind(this));
connection.onDocumentFormatting(this.onDocumentFormatting.bind(this));
connection.onDocumentRangeFormatting(this.onDocumentRangeFormatting.bind(this));
connection.onDocumentOnTypeFormatting(this.onDocumentTypeFormatting.bind(this));
connection.onCodeAction(onCodeActionCallback);
connection.onCodeActionResolve(onCodeActionResolveCallback);
connection.onCodeLens(this.onCodeLens.bind(this));
connection.onFoldingRanges(this.onFoldingRanges.bind(this));
connection.onSelectionRanges(this.onSelectionRanges.bind(this));
connection.onDocumentHighlight(documentHighlightHandler);
connection.languages.inlayHint.on(this.onInlayHints.bind(this));
connection.languages.semanticTokens.on(semanticTokenHandler);
connection.languages.semanticTokens.onRange(semanticTokenHandler);
connection.onSignatureHelp(this.onShowSignatureHelp.bind(this));
connection.onExecuteCommand(commandCallback);
connection.onInitialized(this.onInitialized.bind(this));
connection.onShutdown(this.onShutdown.bind(this));
documents.listen(connection);
documents.onDidOpen(async ({ document }) => {
this.logDocument('documents.onDidOpen', document);
const { uri } = this.analyzeDocument(document);
if (workspaceManager.needsAnalysis() && !this.backgroundAnalysisInProgress) {
logger.info('didOpenTextDocument: Starting workspace analysis with progress');
const progress = await ProgressNotification.create('didOpenTextDocument');
const allDocs = workspaceManager.allAnalysisDocuments().length;
progress.begin(`[fish-lsp] analyzing ${allDocs} documents`, 0, 'open', true);
await workspaceManager.analyzePendingDocuments(progress, (str) => logger.info('didOpen', str));
progress.done();
} else if (this.backgroundAnalysisInProgress) {
logger.info('didOpenTextDocument: Skipping analysis - background analysis already in progress');
}
if (this.backgroundAnalysisComplete) {
analyzer.diagnostics.requestUpdate(uri, true); // full diagnostics pass on open
}
});
documents.onDidChangeContent(({ document }) => {
this.logDocument('documents.onDidChangeContent', document, {
showDiagnostics: true,
showLastChangedSpan: true,
});
const { uri } = this.analyzeDocument(document);
const diagnostics = analyzer.diagnostics.get(uri) || [];
const changeSpan = document.lastChangedLineSpan;
const overlapExists =
!changeSpan
? true
: diagnostics?.some(d => rangeOverlapsLineSpan(d.range, changeSpan));
// Get the first changed line for overlap detection
analyzer.diagnostics.requestUpdate(uri, overlapExists, changeSpan);
});
documents.onDidClose(({ document }) => {
this.logDocument('documents.onDidClose', document);
const { uri } = document;
workspaceManager.handleCloseDocument(uri);
analyzer.diagnostics.delete(uri);
analyzer.removeDocumentSymbols(uri);
});
logger.log({ 'server.register': 'registered' });
}
async didSaveTextDocument(params: LSP.DidSaveTextDocumentParams): Promise {
this.logParams('server.didSaveTextDocument', params);
const document = documents.get(params.textDocument.uri);
if (!document) return;
const { uri } = this.analyzeDocument(document);
await workspaceManager.analyzePendingDocuments();
analyzer.diagnostics.requestUpdate(uri, true); // immediate on save
this.logDocument('didSaveDocument', document, { showDiagnostics: true, showLastChangedSpan: true });
}
/**
* Stop the server and close all workspaces.
*/
async onShutdown() {
analyzer.diagnostics.clear();
workspaceManager.clear();
currentDocument = null;
for (const doc of documents.all()) {
connection.sendDiagnostics({ uri: doc.uri, diagnostics: [] });
}
// this.diagnosticsWorker.dispose();
this.backgroundAnalysisComplete = false;
this.backgroundAnalysisInProgress = false;
}
/**
* Called after the server.onInitialize() handler, dynamically registers
* the onDidChangeWorkspaceFolders handler if the client supports it.
* It will also try to analyze the current workspaces' pending documents.
*/
async onInitialized(params: any): Promise<{
totalDocuments: number;
items: { [path: string]: string[]; };
counts: { [path: string]: number; };
}> {
const supportsProgress = this.initializeParams.capabilities.window?.workDoneProgress;
logger.log(`Progress support: ${supportsProgress}`);
logger.log('onInitialized', params);
logger.log('onInitialized fired');
logger.info('SERVER INITIALIZED', {
buildPath: PkgJson.path,
buildVersion: PkgJson.version,
buildTime: PkgJson.buildTime,
executedAt: now(),
});
if (hasWorkspaceFolderCapability) {
try {
connection.workspace.onDidChangeWorkspaceFolders(event => {
logger.info({
'connection.workspace.onDidChangeWorkspaceFolders': 'analyzer.onInitialized',
added: event.added.map(folder => folder.name),
removed: event.removed.map(folder => folder.name),
hasWorkspaceFolderCapability: hasWorkspaceFolderCapability,
});
this.handleWorkspaceFolderChanges(event);
});
} catch (_) {
// Connection doesn't support workspace folder changes (e.g., in test/diagnostic modes)
logger.debug('Workspace folder change events not supported by this connection');
}
}
let totalDocuments = 0;
let items: { [path: string]: string[]; } = {};
const counts: { [path: string]: number; } = {};
try {
// Set flag BEFORE creating progress to prevent interference
this.backgroundAnalysisInProgress = true;
logger.info('Starting background analysis in onInitialized');
const progress = await ProgressNotification.create('onInitialized');
logger.log('Progress created');
// Begin progress immediately
progress.begin('[fish-lsp] analyzing workspaces', 0);
if (currentDocument) {
logger.info('Re-analyzing current document after background analysis', { uri: currentDocument.uri });
workspaceManager.current?.addPending(...analyzer.collectAllSources(currentDocument.uri));
}
const result = await workspaceManager.analyzePendingDocuments(progress, (str) => logger.info('onInitialized', str));
totalDocuments = result.totalDocuments;
items = result.items;
Object.entries(items).forEach(([key, value]) => {
counts[key] = value.length;
});
progress.done();
this.backgroundAnalysisComplete = true;
this.backgroundAnalysisInProgress = false;
logger.info('Background analysis complete');
if (currentDocument) {
this.analyzeDocument(currentDocument);
analyzer.diagnostics.requestUpdate(currentDocument.uri, true); // full diagnostics pass after analysis
}
} catch (error) {
this.backgroundAnalysisInProgress = false;
this.backgroundAnalysisComplete = false;
logger.error('Error during background analysis onInitialized', error);
}
logger.info(`Initial analysis complete. Analyzed ${totalDocuments} documents.`);
return {
totalDocuments,
items,
counts,
};
}
private async handleWorkspaceFolderChanges(event: WorkspaceFoldersChangeEvent) {
this.logParams('handleWorkspaceFolderChanges', event);
// Show progress for added workspaces
const progress = await ProgressNotification.create('handleWorkspaceFolderChanges');
progress.begin(`[fish-lsp] analyzing workspaces [${event.added.map(s => s.name).join(',')}] added`);
workspaceManager.handleWorkspaceChangeEvent(event, progress);
workspaceManager.analyzePendingDocuments(progress);
progress.done();
}
onCommand(params: LSP.ExecuteCommandParams): Promise {
const callback = createExecuteCommandHandler(connection);
return callback(params);
}
// @TODO: REFACTOR THIS OUT OF SERVER
// https://github.com/Dart-Code/Dart-Code/blob/7df6509870d51cc99a90cf220715f4f97c681bbf/src/providers/dart_completion_item_provider.ts#L197-202
// https://github.com/microsoft/vscode-languageserver-node/pull/322
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#insertTextModehttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#insertTextMode
// • clean up into completion.ts file & Decompose to state machine, with a function that gets the state machine in this class.
// DART is best example i've seen for this.
// ~ https://github.com/Dart-Code/Dart-Code/blob/7df6509870d51cc99a90cf220715f4f97c681bbf/src/providers/dart_completion_item_provider.ts#L197-202 ~
// • Implement both escapedCompletion script and dump syntax tree script
// • Add default CompletionLists to complete.ts
// • Add local file items.
// • Lastly add parameterInformation items. [ 1477 : ParameterInformation ]
// convert to CompletionItem[]
async onCompletion(params: CompletionParams): Promise {
this.logParams('onCompletion', params);
if (!this.backgroundAnalysisComplete) {
return await this.completion.completeEmpty([]);
}
const { doc, path, current } = this.getDefaults(params);
let list: FishCompletionList = FishCompletionList.empty();
if (!path || !doc) {
logger.logAsJson('onComplete got [NOT FOUND]: ' + path);
return this.completion.empty();
}
const symbols = analyzer.allSymbolsAccessibleAtPosition(doc, params.position);
const { line, word } = analyzer.parseCurrentLine(doc, params.position);
// logger.log({
// symbols: symbols.map(s => s.name),
// });
if (!line) return await this.completion.completeEmpty(symbols);
const fishCompletionData = {
uri: doc.uri,
position: params.position,
context: {
triggerKind: params.context?.triggerKind || CompletionTriggerKind.Invoked,
triggerCharacter: params.context?.triggerCharacter,
},
} as SetupData;
try {
if (line.trim().startsWith('#') && current) {
logger.log('completeComment');
return buildCommentCompletions(line, params.position, current, fishCompletionData, word);
}
if (isInVariableExpansionContext(doc, params.position, line, word, current ?? null)) {
logger.log('completeVariables');
return this.completion.completeVariables(line, word, fishCompletionData, symbols);
}
} catch (error) {
logger.warning('ERROR: onComplete ' + error?.toString() || 'error');
}
try {
logger.log('complete');
list = await this.completion.complete(line, fishCompletionData, symbols);
} catch (error) {
logger.logAsJson('ERROR: onComplete ' + error?.toString() || 'error');
}
return list;
}
/**
* until further reworking, onCompletionResolve requires that when a completionBuilderItem() is .build()
* it it also given the method .kind(FishCompletionItemKind) to set the kind of the item.
* Not seeing a completion result, with typed correctly is likely caused from this.
*/
async onCompletionResolve(item: CompletionItem): Promise {
const fishItem = item as FishCompletionItem;
logger.log({ onCompletionResolve: fishItem });
try {
if (fishItem.useDocAsDetail || fishItem.local) {
item.documentation = {
kind: MarkupKind.Markdown,
value: fishItem.documentation.toString(),
};
return item;
}
const doc = await getDocumentationResolver(fishItem);
if (doc) {
item.documentation = doc as MarkupContent;
}
} catch (err) {
logger.error('onCompletionResolve', err);
}
return item;
}
// • lsp-spec: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol
// • hierarchy of symbols support on line 554: https://github.com/typescript-language-server/typescript-language-server/blob/114d4309cb1450585f991604118d3eff3690237c/src/lsp-server.ts#L554
//
// ResolveWorkspaceResult
// https://github.com/Dart-Code/Dart-Code/blob/master/src/extension/providers/dart_workspace_symbol_provider.ts#L7
//
onDocumentSymbols(params: DocumentSymbolParams): DocumentSymbol[] {
this.logParams('onDocumentSymbols', params);
const { doc } = this.getDefaultsForPartialParams(params);
if (!doc) return [];
// Get local document symbols
const localSymbols = analyzer.cache.getDocumentSymbols(doc.uri);
// Get sourced symbols and convert them to nested structure if needed
const sourcedSymbols = analyzer.collectSourcedSymbols(doc.uri);
// Combine local and sourced symbols and cache the sourced symbols as global definitions
// local to the document inside the analyzer workspace. Heuristic to cache global symbols
// more frequently in background analysis of focused document because server.onDocumentSymbols
// is requested repeatedly in most clients when moving around a LspDocument.
[...localSymbols, ...sourcedSymbols]
.filter(s => s.isGlobal() || s.isRootLevel())
.forEach(s => analyzer.globalSymbols.add(s));
return filterLastPerScopeSymbol(localSymbols)
.map(s => s.toDocumentSymbol())
.filter(s => !!s);
}
public get supportHierarchicalDocumentSymbol(): boolean {
const textDocument = this.initializeParams?.capabilities.textDocument;
const documentSymbol = textDocument && textDocument.documentSymbol;
return (
!!documentSymbol &&
!!documentSymbol.hierarchicalDocumentSymbolSupport
);
}
async onWorkspaceSymbol(params: WorkspaceSymbolParams): Promise {
this.logParams('onWorkspaceSymbol', params.query);
const symbols: FishSymbol[] = [];
const workspace = workspaceManager.current;
for (const uri of workspace?.allUris || []) {
const newSymbols = [
...analyzer.cache.getDocumentSymbols(uri),
...analyzer.collectSourcedSymbols(uri),
];
symbols.push(...filterLastPerScopeSymbol(newSymbols));
}
logger.log('symbols', {
uris: workspace?.allUris,
symbols: symbols.map(s => s.name),
});
return analyzer.getWorkspaceSymbols(params.query) || [];
}
/**
* Resolve a workspace symbol to its full definition.
*/
async onWorkspaceSymbolResolve(symbol: WorkspaceSymbol): Promise {
this.logParams('onWorkspaceSymbolResolve', symbol);
const { uri } = symbol.location;
const foundSymbol = analyzer.getFlatDocumentSymbols(uri)
.find(s => s.name === symbol.name && s.isGlobal());
if (foundSymbol) {
return {
...foundSymbol.toWorkspaceSymbol(),
...foundSymbol.toDocumentSymbol(),
};
}
// This is a no-op, as we don't have any additional information to resolve.
// In the future, we could add more information to the symbol if needed.
return symbol;
}
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#showDocumentParams
async onDefinition(params: DefinitionParams): Promise {
this.logParams('onDefinition', params);
const { doc } = this.getDefaults(params);
if (!doc) return [];
const newDefs = analyzer.getDefinitionLocation(doc, params.position);
for (const location of newDefs) {
workspaceManager.handleOpenDocument(location.uri);
workspaceManager.handleUpdateDocument(location.uri);
}
if (workspaceManager.needsAnalysis()) {
await workspaceManager.analyzePendingDocuments();
}
return newDefs;
}
async onReferences(params: ReferenceParams): Promise {
this.logParams('onReference', params);
const { doc } = this.getDefaults(params);
if (!doc) return [];
const progress = await connection.window.createWorkDoneProgress();
const defSymbol = analyzer.getDefinition(doc, params.position);
if (!defSymbol) {
logger.log('onReferences: no definition found at position', params.position);
return [];
}
const results = getReferences(defSymbol.document, defSymbol.toPosition(), {
reporter: progress,
});
logger.info({
onReferences: 'found references',
uri: defSymbol.uri,
count: results.length,
position: params.position,
symbol: defSymbol.name,
});
if (results.length === 0) {
logger.warning('onReferences: no references found', { uri: params.textDocument.uri, position: params.position });
return [];
}
return results;
}
/**
* bi-directional lookup of completion <-> definition under cursor location.
*/
async onImplementation(params: ImplementationParams): Promise {
this.logParams('onImplementation', params);
const { doc } = this.getDefaults(params);
if (!doc) return [];
const symbols = analyzer.cache.getDocumentSymbols(doc.uri);
const lastSymbols = filterLastPerScopeSymbol(symbols);
logger.log('symbols', formatFishSymbolTree(lastSymbols));
const result = analyzer.getImplementation(doc, params.position);
logger.log('implementationResult', { result });
return result;
}
// Probably should move away from `documentationCache`. It works but is too expensive memory wise.
// REFACTOR into a procedure that conditionally determines output type needed.
// Also plan to get rid of any other cache's, so that the garbage collector can do its job.
async onHover(params: HoverParams): Promise {
this.logParams('onHover', {
params: {
uri: params.textDocument.uri,
position: params.position,
},
});
const { doc, path, root, current } = this.getDefaults(params);
if (!doc || !path || !root || !current) {
return null;
}
let result: Hover | null = null;
// case: `./dist/fish-lsp`
if ((isCommand(current) || isCommandName(current)) && isPathNode(current)) {
return {
contents: enrichToMarkdown([
`${md.bold('(command)')} - ${md.italic(current.text)}`,
].join('\n')),
};
}
if (isSourceCommandArgumentName(current)) {
result = handleSourceArgumentHover(analyzer, current, doc);
if (result) return result;
}
if (current.parent && isSourceCommandArgumentName(current.parent)) {
result = handleSourceArgumentHover(analyzer, current.parent, doc);
if (result) return result;
}
if (isAliasDefinitionName(current)) {
result = analyzer.getDefinition(doc, params.position)?.toHover(doc.uri) || null;
if (result) return result;
}
if (isArgparseVariableDefinitionName(current)) {
logger.log('isArgparseDefinition');
result = analyzer.getDefinition(doc, params.position)?.toHover(doc.uri) || null;
return result;
}
if (isOption(current)) {
// check that we aren't hovering a function option that is defined by
// argparse inside the function, if we are then return it's hover value
result = analyzer.getDefinition(doc, params.position)?.toHover(doc.uri) || null;
if (result) return result;
// otherwise we get the hover using inline documentation from `complete --do-complete {option}`
result = await handleHover(
analyzer,
doc,
params.position,
current,
this.documentationCache,
);
if (result) return result;
}
if (isConcatenatedValue(current)) {
logger.log('isConcatenatedValue', { text: current.text, type: current.type });
const parent = findParent(current, isConcatenation);
const brace = findParent(current, isBraceExpansion);
if (parent) {
const res = await handleBraceExpansionHover(parent);
if (res) return res;
}
if (brace) {
const res = await handleBraceExpansionHover(brace);
if (res) return res;
}
}
// handle brace expansion hover
if (isBraceExpansion(current)) {
logger.log('isBraceExpansion', { text: current.text, type: current.type });
const res = await handleBraceExpansionHover(current);
if (res) return res;
}
if (current.parent && isBraceExpansion(current.parent)) {
logger.log('isBraceExpansion: parent', { text: current.parent.text, type: current.parent.type });
const res = await handleBraceExpansionHover(current.parent);
if (res) return res;
}
if (isEndStdinCharacter(current)) {
return handleEndStdinHover(current);
}
const { kindType, kindString } = symbolKindsFromNode(current);
logger.log({ currentText: current.text, currentType: current.type, symbolKind: kindString });
const prebuiltSkipType = [
...PrebuiltDocumentationMap.getByType('pipe'),
...isReturnStatusNumber(current) ? PrebuiltDocumentationMap.getByType('status') : [],
].find(obj => obj.name === current.text);
// documentation for prebuilt variables without definition's
// including $status, $pipestatus, $fish_pid, etc.
// See: PrebuiltDocumentationMap.getByType('variable') for entire list
// Also includes autoloaded variables: $fish_complete_path, $__fish_data_dir, etc...
const isPrebuiltVariableWithoutDefinition = getVariableExpansionDocs(analyzer, doc, params.position);
const prebuiltHover = isPrebuiltVariableWithoutDefinition(current);
if (prebuiltHover) return prebuiltHover;
const symbolItem = analyzer.getHover(doc, params.position);
if (symbolItem) return symbolItem;
if (prebuiltSkipType) {
return {
contents: enrichToMarkdown([
`___${current.text}___ - _${getPrebuiltDocUrl(prebuiltSkipType)}_`,
'___',
`type - __(${prebuiltSkipType.type})__`,
'___',
`${prebuiltSkipType.description}`,
].join('\n')),
};
}
const definition = analyzer.getDefinition(doc, params.position);
const allowsGlobalDocs = !definition || definition?.isGlobal();
const symbolType = [
'function',
'class',
'variable',
].includes(kindString) ? kindType : undefined;
const globalItem = await this.documentationCache.resolve(
current.text.trim(),
path,
symbolType,
);
logger.log(`this.documentationCache.resolve() found ${!!globalItem}`, { docs: globalItem.docs });
if (globalItem && globalItem.docs && allowsGlobalDocs) {
logger.log({ ...globalItem });
return {
contents: {
kind: MarkupKind.Markdown,
value: globalItem.docs,
},
};
}
const fallbackHover = await handleHover(
analyzer,
doc,
params.position,
current,
this.documentationCache,
);
logger.log({
hover: { ...params },
...fallbackHover,
});
return fallbackHover;
}
async onRename(params: RenameParams): Promise {
this.logParams('onRename', params);
const { doc } = this.getDefaults(params);
if (!doc) return null;
const locations = getRenames(doc, params.position, params.newName);
const changes: { [uri: string]: TextEdit[]; } = {};
for (const location of locations) {
const range = location.range;
const uri = location.uri;
const edits = changes[uri] || [];
edits.push(TextEdit.replace(range, location.newText));
changes[uri] = edits;
}
const workspaceEdit: WorkspaceEdit = {
changes,
};
return workspaceEdit;
}
async onDocumentFormatting(params: DocumentFormattingParams): Promise {
this.logParams('onDocumentFormatting', params);
const { doc } = this.getDefaultsForPartialParams(params);
if (!doc) return [];
const formattedText = await formatDocumentWithIndentComments(doc).catch(error => {
if (config.fish_lsp_show_client_popups) {
connection.window.showErrorMessage(`Failed to format document: ${error}`);
}
return doc.getText(); // fallback to original text on error
});
return [{
range: LSP.Range.create(
LSP.Position.create(0, 0),
LSP.Position.create(Number.MAX_VALUE, Number.MAX_VALUE),
),
newText: formattedText,
}];
}
async onDocumentTypeFormatting(params: DocumentFormattingParams): Promise {
this.logParams('onDocumentTypeFormatting', params);
const { doc } = this.getDefaultsForPartialParams(params);
if (!doc) return [];
const formattedText = await formatDocumentWithIndentComments(doc).catch(error => {
connection.console.error(`Formatting error: ${error}`);
if (config.fish_lsp_show_client_popups) {
connection.window.showErrorMessage(`Failed to format document: ${error}`);
}
return doc.getText(); // fallback to original text on error
});
return [{
range: LSP.Range.create(
LSP.Position.create(0, 0),
LSP.Position.create(Number.MAX_VALUE, Number.MAX_VALUE),
),
newText: formattedText,
}];
}
/**
* Currently only works for whole line selections, in the future we should try to make every
* selection a whole line selection.
*/
async onDocumentRangeFormatting(params: DocumentRangeFormattingParams): Promise {
this.logParams('onDocumentRangeFormatting', params);
const { doc } = this.getDefaultsForPartialParams(params);
if (!doc) return [];
const range = params.range;
const startOffset = doc.offsetAt(range.start);
const endOffset = doc.offsetAt(range.end);
// get the text
const originalText = doc.getText();
const selectedText = doc.getText().slice(startOffset, endOffset).trimStart();
// Call the formatter 2 differently times, once for the whole document (to get the indentation level)
// and a second time to get the specific range formatted
const allText = await formatDocumentContent(originalText).catch((error) => {
logger.error(`FormattingRange error: ${error}`);
return selectedText; // fallback to original text on error
});
const formattedText = await formatDocumentContent(selectedText).catch(error => {
logger.error(`FormattingRange error: ${error}`, {
input: selectedText,
range: range,
});
if (config.fish_lsp_show_client_popups) {
connection.window.showErrorMessage(`Failed to format range: ${params.textDocument.uri}`);
}
return selectedText;
});
// Create a temporary TextDocumentItem with the formatted text, for passing to formatTextWithIndents()
const newDoc = LspDocument.createTextDocumentItem(doc.uri, allText);
// fixup formatting, so that we end with a single newline character (important for inserting `TextEdit`)
const output = formatTextWithIndents(
newDoc,
range.start.line,
formattedText.trim(),
) + '\n';
return [
TextEdit.replace(
params.range,
output,
),
];
}
async onFoldingRanges(params: FoldingRangeParams): Promise {
this.logParams('onFoldingRanges', params);
const { path, doc } = this.getDefaultsForPartialParams(params);
if (!doc) {
throw new Error(`The document should not be opened in the folding range, file: ${path}`);
}
//this.analyzer.analyze(document)
const symbols = analyzer.getDocumentSymbols(doc.uri);
const flatSymbols = flattenNested(...symbols);
logger.logPropertiesForEachObject(
flatSymbols.filter((s) => s.kind === SymbolKind.Function),
'name',
'range',
);
const folds = flatSymbols
.filter((symbol) => symbol.kind === SymbolKind.Function)
.map((symbol) => symbol.toFoldingRange());
folds.forEach((fold) => logger.log({ fold }));
return folds;
}
async onSelectionRanges(params: SelectionRangeParams): Promise {
this.logParams('onSelectionRanges', params);
const { doc } = this.getDefaultsForPartialParams(params);
if (!doc) {
return null;
}
return getSelectionRanges(doc, params.positions);
}
async onInlayHints(params: InlayHintParams) {
logger.log({ params });
const { doc } = this.getDefaultsForPartialParams(params);
if (!doc) return [];
return getAllInlayHints(analyzer, doc);
}
// https://code.visualstudio.com/api/language-extensions/programmatic-language-features#codelens-show-actionable-context-information-within-source-code
async onCodeLens(params: CodeLensParams): Promise {
logger.log('onCodeLens', params);
// const path = uriToPath(params.textDocument.uri);
const doc = documents.get(params.textDocument.uri);
if (!doc) return [];
return getReferenceCountCodeLenses(analyzer, doc);
}
public onShowSignatureHelp(params: SignatureHelpParams): SignatureHelp | null {
try {
this.logParams('onShowSignatureHelp', params);
const { doc, path } = this.getDefaults(params);
if (!doc || !path) return null;
const { line, lineRootNode, lineLastNode } = analyzer.parseCurrentLine(doc, params.position);
if (line.trim() === '') return null;
const currentCmd = findParentCommand(lineLastNode)!;
const aliasSignature = this.completionMap.allOfKinds('alias').find(a => a.label === currentCmd.text);
if (aliasSignature) return getAliasedCompletionItemSignature(aliasSignature);
const varNode = getChildNodes(lineRootNode).find(c => isVariableDefinition(c));
const lastCmd = getChildNodes(lineRootNode).filter(c => isCommand(c)).pop();
logger.log({ line, lastCmds: lastCmd?.text });
if (varNode && (line.startsWith('set') || line.startsWith('read')) && lastCmd?.text === lineRootNode.text.trim()) {
const varName = varNode.text;
const varDocs = PrebuiltDocumentationMap.getByName(varNode.text);
if (!varDocs.length) return null;
return {
signatures: [
{
label: varName,
documentation: {
kind: 'markdown',
value: varDocs.map(d => d.description).join('\n'),
},
},
],
activeSignature: 0,
activeParameter: 0,
};
}
if (isRegexStringSignature(line)) {
const signature = getDefaultSignatures();
logger.log('signature', signature);
const cursorLineOffset = line.length - lineLastNode.endIndex;
const { activeParameter } = findActiveParameterStringRegex(line, cursorLineOffset);
signature.activeParameter = activeParameter;
return signature;
}
const functionSignature = getFunctionSignatureHelp(
analyzer,
lineLastNode,
line,
params.position,
);
if (functionSignature) return functionSignature;
} catch (err) {
logger.error('onShowSignatureHelp', err);
}
return null;
}
/**
* Parse and analyze a document. Adds diagnostics to the document, and finds `source` commands.
* @param document - The document identifier to analyze
*/
public analyzeDocument(document: LspDocument) {
// remove existing symbols from analyzer.cache, as they will be re-collected
analyzer.removeDocumentSymbols(document.uri);
const { document: doc } = analyzer.analyze(document).ensureParsed();
// re-indexes the workspace and changes the current workspace to the document (if needed)
workspaceManager.handleOpenDocument(doc);
workspaceManager.handleUpdateDocument(doc);
currentDocument = doc;
return {
uri: doc.uri,
path: doc.path,
doc: doc,
};
}
/**
* Getter for information about the server.
*
* Mostly from the `../package.json` file of this module, but also includes
* other useful entries about the server such as `out/build-time.json` object,
* `manPath` and certain url entries that are slightly modified for easier
* access to their links.
*/
public get info() {
return PkgJson;
}
/**
* Getter for the completion item map (all commands available at startup)
*/
public get completions(): CompletionItemMap {
return this.completionMap;
}
public static get instance(): FishServer {
if (!server) throw new Error('FishServer instance not initialized yet.');
return server;
}
public static throwError(message: string) {
throw new Error(message);
}
/////////////////////////////////////////////////////////////////////////////////////
// HELPERS
/////////////////////////////////////////////////////////////////////////////////////
/**
* Logs the params passed into a handler
*
* @param {string} methodName - the FishLsp method name that was called
* @param {any[]} params - the params passed into the method
*/
private logParams(methodName: string, ...params: any[]) {
logger.log({ time: now(), handler: methodName, params });
}
// helper to get all the default objects needed when a TextDocumentPositionParam is passed
// into a handler
private getDefaults(params: TextDocumentPositionParams): {
doc?: LspDocument;
path?: string;
root?: SyntaxNode | null;
current?: SyntaxNode | null;
} {
const doc = documents.get(params.textDocument.uri);
const path = doc?.path ?? uriToPath(params.textDocument.uri);
if (!doc || !path) return { path };
const root = analyzer.getRootNode(doc.uri);
const current = analyzer.nodeAtPoint(
doc.uri,
params.position.line,
params.position.character,
);
return { doc, path, root, current };
}
private getDefaultsForPartialParams(params: {
textDocument: TextDocumentIdentifier;
}): {
doc?: LspDocument;
path: string;
root?: SyntaxNode | null;
} {
const doc = documents.get(params.textDocument.uri);
const path = doc?.path ?? uriToPath(params.textDocument.uri);
const root = doc ? analyzer.getRootNode(doc.uri) : undefined;
return { doc, path, root };
}
private logDocument(request: string, document: LspDocument | undefined, options: {
showDiagnostics?: boolean;
showLastChangedSpan?: boolean;
} = {
showDiagnostics: false,
showLastChangedSpan: false,
}) {
const extra: any = {};
if (document) {
const { uri, version, lineCount } = document;
const content = document.getText();
const truncated = content.length > 200 ? content.substring(0, 200) + '...' : content;
if (options.showDiagnostics) {
extra.diagnostics = { count: (analyzer.diagnostics.get(document.uri) || []).length };
extra.diagnosticsInSpan = (analyzer.diagnostics.get(document.uri) || []).filter(d => {
return document.lastChangedLineSpan
? rangeOverlapsLineSpan(d.range, document.lastChangedLineSpan)
: false;
}).map(d => ({
text: d.code,
line: d.range.start.line,
span: document.lastChangedLineSpan,
}));
}
if (options.showLastChangedSpan) {
extra.lastChangedSpan = document.lastChangedLineSpan;
}
logger.log({
time: now(),
request,
uri,
version,
content: truncated,
lineCount,
...extra,
});
} else {
logger.log({ time: now(), request, document: 'undefined', ...extra });
}
}
static async setupForTestUtilities() {
await setupProcessEnvExecFile();
// const capabilities = params.capabilities;
const initializeResult = Config.initialize({} as InitializeParams, connection);
// set this only it it hasn't been set yet
const initializeUris = getWorkspacePathsFromInitializationParams({} as InitializeParams);
// Run these operations in parallel rather than sequentially
const [
cache,
_workspaces,
completionsMap,
] = await Promise.all([
initializeDocumentationCache(),
initializeDefaultFishWorkspaces(...initializeUris),
CompletionItemMap.initialize(),
]);
cachedDocumentation = cache;
cachedCompletionMap = completionsMap;
await Analyzer.initialize();
const completions = await initializeCompletionPager(logger, completionsMap);
server = new FishServer(
completions,
completionsMap,
cache,
{} as InitializeParams,
);
return { server, initializeResult };
}
}
// Type export
export {
FishServer,
};
================================================
FILE: src/signature.ts
================================================
import {
MarkupContent,
MarkupKind,
ParameterInformation,
Position,
SignatureHelp,
SignatureInformation,
SymbolKind,
} from 'vscode-languageserver';
import { SyntaxNode } from 'web-tree-sitter';
import { ExtendedBaseJson, PrebuiltDocumentationMap } from './utils/snippets';
import { FishAliasCompletionItem } from './utils/completion/types';
import * as NodeTypes from './utils/node-types';
import * as TreeSitter from './utils/tree-sitter';
import { CompletionItemMap } from './utils/completion/startup-cache';
import { Option } from './parsing/options';
import { Analyzer } from './analyze';
import { md } from './utils/markdown-builder';
import { symbolKindToString } from './utils/translation';
export function buildSignature(label: string, value: string): SignatureInformation {
return {
label: label,
documentation: {
kind: 'markdown',
value: value,
},
};
}
export function getCurrentNodeType(input: string) {
const prebuiltTypes = PrebuiltDocumentationMap.getByName(input);
if (!prebuiltTypes || prebuiltTypes.length === 0) {
return null;
}
let longestDocs = prebuiltTypes[0]!;
for (const prebuilt of prebuiltTypes) {
if (prebuilt.description.length > longestDocs.description.length) {
longestDocs = prebuilt;
}
}
return longestDocs;
}
export function lineSignatureBuilder(lineRootNode: SyntaxNode, lineCurrentNode: SyntaxNode, _completeMmap: CompletionItemMap): SignatureHelp | null {
const currentCmd = NodeTypes.findParentCommand(lineCurrentNode) || lineRootNode;
const pipes = getPipes(lineRootNode);
const varNode = getVariableNode(lineRootNode);
const allCmds = getAllCommands(lineRootNode);
const regexOption = getRegexOption(lineRootNode);
if (pipes.length === 1) return getPipesSignature(pipes);
switch (true) {
case isStringWithRegex(currentCmd.text, regexOption):
return getDefaultSignatures();
case varNode && isSetOrReadWithVarNode(currentCmd?.text || lineRootNode.text, varNode, lineRootNode, allCmds):
return getSignatureForVariable(varNode);
case currentCmd?.text.startsWith('return') || lineRootNode.text.startsWith('return'):
return getReturnStatusSignature();
case allCmds.length === 1:
return getCommandSignature(currentCmd);
default:
return null;
}
}
export function getPipes(rootNode: SyntaxNode): ExtendedBaseJson[] {
const pipeNames = PrebuiltDocumentationMap.getByType('pipe');
return TreeSitter.getChildNodes(rootNode).reduce((acc: ExtendedBaseJson[], node) => {
const pipe = pipeNames.find(p => p.name === node.text);
if (pipe) acc.push(pipe);
return acc;
}, []);
}
function getVariableNode(rootNode: SyntaxNode): SyntaxNode | undefined {
return TreeSitter.getChildNodes(rootNode).find(c => NodeTypes.isVariableDefinition(c));
}
function getAllCommands(rootNode: SyntaxNode): SyntaxNode[] {
return TreeSitter.getChildNodes(rootNode).filter(c => NodeTypes.isCommand(c));
}
function getRegexOption(rootNode: SyntaxNode): SyntaxNode | undefined {
return TreeSitter.getChildNodes(rootNode).find(n => NodeTypes.isMatchingOption(n, Option.create('-r', '--regex')));
}
function isStringWithRegex(line: string, regexOption: SyntaxNode | undefined): boolean {
return line.startsWith('string') && !!regexOption;
}
function isSetOrReadWithVarNode(line: string, varNode: SyntaxNode | undefined, rootNode: SyntaxNode, allCmds: SyntaxNode[]): boolean {
return !!varNode && (line.startsWith('set') || line.startsWith('read')) && allCmds.pop()?.text === rootNode.text.trim();
}
function getSignatureForVariable(varNode: SyntaxNode): SignatureHelp | null {
const output = getCurrentNodeType(varNode.text);
if (!output) return null;
return {
signatures: [buildSignature(output.name, output.description)],
activeSignature: 0,
activeParameter: 0,
};
}
function getReturnStatusSignature(): SignatureHelp {
const output = PrebuiltDocumentationMap.getByType('status').map((o: ExtendedBaseJson) => `___${o.name}___ - _${o.description}_`).join('\n');
return {
signatures: [buildSignature('$status', output)],
activeSignature: 0,
activeParameter: 0,
};
}
function getPipesSignature(pipes: ExtendedBaseJson[]): SignatureHelp {
return {
signatures: pipes.map((o: ExtendedBaseJson) => buildSignature(o.name, `${o.name} - _${o.description}_`)),
activeSignature: 0,
activeParameter: 0,
};
}
function getCommandSignature(firstCmd: SyntaxNode): SignatureHelp {
const output = PrebuiltDocumentationMap.getByType('command').filter(n => n.name === firstCmd.text);
return {
signatures: [buildSignature(firstCmd.text, output.map((o: ExtendedBaseJson) => `${o.name} - _${o.description}_`).join('\n'))],
activeSignature: 0,
activeParameter: 0,
};
}
export function getAliasedCompletionItemSignature(item: FishAliasCompletionItem): SignatureHelp {
// const output = PrebuiltDocumentationMap.getByType('command').filter(n => n.name === firstCmd.text);
return {
signatures: [buildSignature(item.label, [
'```fish',
`${item.fishKind} ${item.label} ${item.detail}`,
'```',
].join('\n'))],
activeSignature: 0,
activeParameter: 0,
};
}
export function regexStringSignature(): SignatureInformation {
const signatureDoc: MarkupContent = {
kind: 'markdown',
value: [
markdownStringRepetitions,
markdownStringCharClasses,
markdownStringGroups,
].join('\n---\n'),
};
return {
label: 'Regex Patterns',
documentation: signatureDoc,
};
}
function regexStringCharacterSets(): SignatureInformation {
const inputText: string = [
markdownStringRepetitions,
markdownStringCharClasses,
markdownStringGroups,
].join('\n---\n');
const parameters: ParameterInformation[] = [
ParameterInformation.create('argv[1]', inputText),
ParameterInformation.create('argv[2]', inputText),
];
return {
label: 'Regex Groups',
documentation: {
kind: 'markdown',
value: markdownStringCharacterSets,
} as MarkupContent,
parameters: parameters,
activeParameter: 0,
};
}
/**
* Checks if a flag matches either a short flag (-r) or a long flag (--regex)
* For short flags, it will check if the flag is part of a combined flag string (-re)
*
* @param text The text to check
* @param shortFlag The short flag to check for (e.g. 'r')
* @param longFlag The long flag to check for (e.g. 'regex')
* @returns true if the text matches either the short or long flag
*/
export function isMatchingOption(
text: string,
options: { shortOption?: string; longOption?: string; },
): boolean {
// Early return if text doesn't start with a dash
if (!text.startsWith('-')) return false;
// Handle long options (--option)
if (text.startsWith('--') && options.longOption) {
// Remove any equals sign and following text (--option=value)
const cleanText = text.includes('=') ? text.slice(0, text.indexOf('=')) : text;
return cleanText === `--${options.longOption}`;
}
// Handle short options (-o)
if (text.startsWith('-') && options.shortOption) {
// Check if the short option is included in the characters after the dash
// This handles combined flags like -abc where we want to check for 'a'
return text.slice(1).includes(options.shortOption);
}
return false;
}
/**
* Determines the active parameter index based on cursor position
*
* @param line The complete command line
* @param commandName The name of the command
* @param cursorPosition The position of the cursor in the line
* @returns The index of the active parameter
*/
export function getActiveParameterIndex(line: string, commandName: string, needsSubcommand: boolean, cursorPosition: number): number {
// Split the line into tokens
const tokens = line.trim().split(/\s+/);
let currentPosition = 0;
let paramIndex = 0;
const commands = commandName.split(' ');
let previousWasCommand = false;
for (const token of tokens) {
if (commands.includes(token) || ['if', 'else if', 'switch', 'case'].includes(token)) {
// Skip the command name
cursorPosition += token.length + 1; // +1 for the space
previousWasCommand = true;
continue;
}
// Skip the subcommand
if (needsSubcommand && previousWasCommand) {
cursorPosition += token.length + 1; // +1 for the space
previousWasCommand = false;
continue;
}
break;
}
// Find which parameter the cursor is in
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
// Check if cursor is before this token
if (currentPosition + token!.length >= cursorPosition) {
break;
}
// If token is a flag, it's not a parameter
if (token!.startsWith('-')) {
// Skip flag parameter if it's a value flag
if (i + 1 < tokens.length && !tokens[i + 1]!.startsWith('-')) {
i++; // Skip the value
currentPosition += tokens[i]!.length + 1;
}
} else {
// This is a parameter
paramIndex++;
}
currentPosition += token!.length + 1; // +1 for the space
}
return paramIndex;
}
/**
* Check if the input line is a string command with regex option
*/
export function isRegexStringSignature(line: string): boolean {
const tokens = line.split(' ');
const hasStringCommand = tokens.some(token => token === 'string') && !tokens.some(token => token === '--');
if (hasStringCommand) {
return tokens.some(token =>
isMatchingOption(token, {
shortOption: 'r',
longOption: 'regex',
}),
);
}
return false;
}
export function findActiveParameterStringRegex(
line: string,
cursorPosition: number,
): {
isRegex: boolean;
activeParameter: number;
} {
const tokens = line.split(' ');
const hasStringCommand = tokens.some(token => token === 'string');
const isRegex = hasStringCommand && tokens.some(token =>
isMatchingOption(token, {
shortOption: 'r',
longOption: 'regex',
}),
);
const activeParameter = isRegex ? getActiveParameterIndex(line, 'string ', true, cursorPosition) : 0;
return { isRegex, activeParameter };
}
type signatureType = 'stringRegexPatterns' | 'stringRegexCharacterSets';
export const signatureIndex: { [str in signatureType]: number } = {
stringRegexPatterns: 0,
stringRegexCharacterSets: 1,
};
export function getDefaultSignatures(): SignatureHelp {
return {
activeParameter: 0,
activeSignature: 0,
signatures: [
regexStringSignature(),
regexStringCharacterSets(),
],
};
}
/**
* Creates a signature help for a function
*
* @param analyzer The analyzer instance
* @param lineLastNode The last node in the current line
* @param line The current line text
* @param position The cursor position
* @returns A SignatureHelp object or null
*/
export function getFunctionSignatureHelp(
analyzer: Analyzer,
lineLastNode: SyntaxNode,
line: string,
position: Position,
): SignatureHelp | null {
// Find the function symbol based on the node's parent's first named child
const functionName = lineLastNode.parent?.firstNamedChild?.text.trim();
if (!functionName) return null;
const funcSymbol = analyzer.findSymbol((symbol, _) => symbol.name === functionName);
if (!funcSymbol || funcSymbol.kind !== SymbolKind.Function) return null;
// Get all parameter names, filtering out non-function variables
const paramNames = funcSymbol.children
.filter(s => s.fishKind === 'FUNCTION_VARIABLE' && s.name !== 'argv');
// Add argv as the last parameter if it exists
const argvParam = funcSymbol.children
.find(s => s.fishKind === 'FUNCTION_VARIABLE' && s.name === 'argv');
if (argvParam) {
paramNames.push(argvParam);
}
// Create parameter information for each parameter
const paramDocs: ParameterInformation[] = paramNames.map((p, idx) => {
const markdownString = p.toMarkupContent().value.split(md.separator());
// set the labels for `argv` to be `$argv[1..-1]` and the rest to be `$argv[1]`
const label = p.name === 'argv'
? `$${p.name}[${idx + 1}..-1]`
: p.name;
// set the documentation to be the first line of the markdown string
const newContentString = p.name === 'argv'
? [
'',
`${md.bold(`(${symbolKindToString(p.kind)})`)} ${label}`,
md.separator(),
`This parameter corresponds to ${md.inlineCode(`$argv[${idx + 1}..-1]`)} in the function.`,
'',
].join(md.newline())
: [
'',
`${md.bold(`(${symbolKindToString(p.kind)})`)} ${md.inlineCode(p.name)}`,
md.separator(),
`This parameter corresponds to ${md.inlineCode(`$argv[${idx + 1}]`)} in the function.`,
'',
].join(md.newline());
// set the documentation
const newValue = p.name === 'argv'
? [
newContentString,
].join(md.separator())
: [
newContentString,
markdownString.slice(3, 4),
].join(md.separator());
// set content
const newContent = {
kind: MarkupKind.Markdown,
value: newValue,
};
return {
label: label,
documentation: newContent,
};
});
// Create the signature label with the function name and parameter names
const label = `${funcSymbol.name} ${paramDocs.map(p => p.label).join(' ')}`.trim();
// Create the signature information
const signature = SignatureInformation.create(
label,
funcSymbol.detail,
...paramDocs,
);
signature.documentation = {
kind: MarkupKind.Markdown,
value: funcSymbol.detail || 'No documentation available',
};
// Calculate the active parameter based on cursor position
const activeParameter = calculateActiveParameter(line, position) - 1;
return {
signatures: [signature],
activeSignature: 0,
activeParameter: Math.min(activeParameter, paramNames.length - 1),
};
}
/**
* Calculates which parameter the cursor is currently on
*
* @param line The current line text
* @param position The cursor position
* @returns The index of the active parameter
*/
function calculateActiveParameter(line: string, position: Position): number {
const textBeforeCursor = line.substring(0, position.character);
const tokens = textBeforeCursor.trim().split(/\s+/);
// First token is the function name, so we start at 0 (first parameter)
// and count parameters (non-flag arguments)
let paramCount = 0;
// Skip the first token (function name)
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
// Skip flags and their values
if (token?.startsWith('-')) {
// If this is a flag that takes a value and the next token exists
// and isn't a flag, skip that too
if (i + 1 < tokens.length && !tokens[i + 1]?.startsWith('-')) {
i++;
}
continue;
}
// Count this as a parameter
paramCount++;
}
return paramCount;
}
// REGEX STRING LINES
const markdownStringRepetitions = [
'Repetitions',
'-----------',
'- __*__ refers to 0 or more repetitions of the previous expression',
'- __+__ 1 or more',
'- __?__ 0 or 1.',
'- __{n}__ to exactly n (where n is a number)',
'- __{n,m}__ at least n, no more than m.',
'- __{n,}__ n or more',
].join('\n');
const markdownStringCharClasses = [
'Character Classes',
'-----------------',
'- __.__ any character except newline',
'- __\\d__ a decimal digit and __\\D__, not a decimal digit',
'- __\\s__ whitespace and __\\S__, not whitespace',
'- __\\w__ a “word” character and __\\W__, a “non-word” character',
'- __\\b__ a “word” boundary, and __\\B__, not a word boundary',
'- __[...]__ (where “…” is some characters) is a character set',
'- __[^...]__ is the inverse of the given character set',
'- __[x-y]__ is the range of characters from x-y',
'- __[[:xxx:]]__ is a named character set',
'- __[[:^xxx:]]__ is the inverse of a named character set',
].join('\n');
const markdownStringCharacterSets = [
'__[[:alnum:]]__ : “alphanumeric”',
'__[[:alpha:]]__ : “alphabetic”',
'__[[:ascii:]]__ : “0-127”',
'__[[:blank:]]__ : “space or tab”',
'__[[:cntrl:]]__ : “control character”',
'__[[:digit:]]__ : “decimal digit”',
'__[[:graph:]]__ : “printing, excluding space”',
'__[[:lower:]]__ : “lower case letter”',
'__[[:print:]]__ : “printing, including space”',
'__[[:punct:]]__ : “printing, excluding alphanumeric”',
'__[[:space:]]__ : “white space”',
'__[[:upper:]]__ : “upper case letter”',
'__[[:word:]]__ : “same as w”',
'__[[:xdigit:]]__ : “hexadecimal digit”',
].join('\n');
const markdownStringGroups = [
'Groups',
'------',
'- __(...)__ is a capturing group',
'- __(?:...)__ is a non-capturing group',
'- __\\n__ is a backreference (where n is the number of the group, starting with 1)',
'- __$n__ is a reference from the replacement expression to a group in the match expression.',
].join('\n');
================================================
FILE: src/snippets/envVariables.json
================================================
[
{
"name": "_",
"description": "the name of the currently running command (though this is deprecated, and the use of status current-command is preferred)."
},
{
"name": "argv",
"description": "a list of arguments to the shell or function. argv is only defined when inside a function call, or if fish was invoked with a list of arguments, like fish myscript.fish foo bar. This variable can be changed."
},
{
"name": "argv_opts",
"description": "argparse sets this to the list of successfully parsed options, including option-arguments. This variable can be changed."
},
{
"name": "CMD_DURATION",
"description": "the runtime of the last command in milliseconds."
},
{
"name": "COLUMNS",
"description": "the current size of the terminal in width. These values are only used by fish if the operating system does not report the size of the terminal. Both variables must be set in that case otherwise a default of 80x24 will be used. They are updated when the window size changes."
},
{
"name": "LINES",
"description": "the current size of the terminal in height. These values are only used by fish if the operating system does not report the size of the terminal. Both variables must be set in that case otherwise a default of 80x24 will be used. They are updated when the window size changes."
},
{
"name": "fish_kill_signal",
"description": "the signal that terminated the last foreground job, or 0 if the job exited normally."
},
{
"name": "fish_killring",
"description": "a list of entries in fish’s kill ring of cut text."
},
{
"name": "fish_read_limit",
"description": "how many bytes fish will process with read or in a command substitution."
},
{
"name": "fish_pid",
"description": "the process ID (PID) of the shell."
},
{
"name": "history",
"description": "a list containing the last commands that were entered."
},
{
"name": "HOME",
"description": "the user’s home directory. This variable can be changed."
},
{
"name": "hostname",
"description": "the machine’s hostname."
},
{
"name": "IFS",
"description": "the internal field separator that is used for word splitting with the read builtin. Setting this to the empty string will also disable line splitting in command substitution. This variable can be changed."
},
{
"name": "last_pid",
"description": "the process ID (PID) of the last background process."
},
{
"name": "PWD",
"description": "the current working directory."
},
{
"name": "pipestatus",
"description": "a list of exit statuses of all processes that made up the last executed pipe. See exit status."
},
{
"name": "SHLVL",
"description": "the level of nesting of shells. Fish increments this in interactive shells, otherwise it only passes it along."
},
{
"name": "status",
"description": "the exit status of the last foreground job to exit. If the job was terminated through a signal, the exit status will be 128 plus the signal number."
},
{
"name": "status_generation",
"description": "the “generation” count of $status. This will be incremented only when the previous command produced an explicit status. (For example, background jobs will not increment this)."
},
{
"name": "TERM",
"description": "the type of the current terminal. When fish tries to determine how the terminal works - how many colors it supports, what sequences it sends for keys and other things - it looks at this variable and the corresponding information in the terminfo database (see man terminfo). Note: Typically this should not be changed as the terminal sets it to the correct value."
},
{
"name": "USER",
"description": "the current username. This variable can be changed."
},
{
"name": "EUID",
"description": "the current effective user id, set by fish at startup. This variable can be changed."
},
{
"name": "version",
"description": "the version of the currently running fish (also available as FISH_VERSION for backward compatibility)."
}
]
================================================
FILE: src/snippets/fishlspEnvVariables.json
================================================
[
{
"name": "fish_lsp_enabled_handlers",
"description": "Enables the fish-lsp handlers. By default, all stable handlers are enabled.",
"shortDescription": "server handlers to enable",
"exactMatchOptions": true,
"options": [
"complete",
"hover",
"rename",
"definition",
"implementation",
"reference",
"logger",
"formatting",
"formatRange",
"typeFormatting",
"codeAction",
"codeLens",
"folding",
"selectionRange",
"signature",
"executeCommand",
"inlayHint",
"highlight",
"diagnostic",
"popups",
"semanticTokens"
],
"defaultValue": [],
"valueType": "array"
},
{
"name": "fish_lsp_disabled_handlers",
"description": "Disables the fish-lsp handlers. By default, non-stable handlers are disabled.",
"shortDescription": "server handlers to disable",
"exactMatchOptions": true,
"options": [
"complete",
"hover",
"rename",
"definition",
"implementation",
"reference",
"logger",
"formatting",
"formatRange",
"typeFormatting",
"codeAction",
"codeLens",
"folding",
"selectionRange",
"signature",
"executeCommand",
"inlayHint",
"highlight",
"diagnostic",
"popups",
"semanticTokens"
],
"defaultValue": [],
"valueType": "array"
},
{
"name": "fish_lsp_commit_characters",
"description": "Array of the completion expansion characters.\n\nSingle letter values only.\n\nCommit characters are used to select completion items, as shortcuts.",
"shortDescription": "commit characters that select completion items",
"exactMatchOptions": false,
"options": [
".",
",",
";",
":",
"(",
")",
"[",
"]",
"{",
"}",
"<",
">",
"'",
"\"",
"=",
"+",
"-",
"/",
"\\",
"|",
"&",
"%",
"$",
"#",
"@",
"!",
"?",
"*",
"^",
"`",
"~",
"\\t",
" "
],
"defaultValue": [
"\\t",
";",
" "
],
"valueType": "array"
},
{
"name": "fish_lsp_log_file",
"description": "A path to the fish-lsp's logging file. Empty string disables logging.",
"shortDescription": "path to the fish-lsp's log file",
"exactMatchOptions": false,
"options": [
"/tmp/fish_lsp.log",
"~/path/to/fish_lsp/logs.txt"
],
"defaultValue": "",
"valueType": "string"
},
{
"name": "fish_lsp_logfile",
"description": "DEPRECATED. USE `fish_lsp_log_file` instead.\n\nPath to the logging file.",
"shortDescription": "path to the fish-lsp's log file",
"isDeprecated": true,
"exactMatchOptions": false,
"options": [
"/tmp/fish_lsp.logs",
"~/path/to/fish_lsp/logs.txt"
],
"defaultValue": "",
"valueType": "string"
},
{
"name": "fish_lsp_log_level",
"description": "The logging severity level for displaying messages in the log file.",
"shortDescription": "minimum log level to include in the log file",
"exactMatchOptions": true,
"options": [
"debug",
"info",
"warning",
"error",
"log"
],
"defaultValue": "",
"valueType": "string"
},
{
"name": "fish_lsp_all_indexed_paths",
"description": "The fish file paths to include in the fish-lsp's startup indexing, as workspaces.\n\nOrder matters (usually place `$__fish_config_dir` before `$__fish_data_dir`).",
"shortDescription": "directories that the server should always index on startup",
"exactMatchOptions": false,
"options": [
"$HOME/.config/fish",
"/usr/share/fish",
"$__fish_config_dir",
"$__fish_data_dir"
],
"defaultValue": [
"$__fish_config_dir",
"$__fish_data_dir"
],
"valueType": "array"
},
{
"name": "fish_lsp_modifiable_paths",
"description": "The fish file paths, for workspaces where global symbols can be renamed by the user.",
"shortDescription": "indexed paths that can be modified",
"exactMatchOptions": false,
"options": [
"/usr/share/fish",
"$HOME/.config/fish",
"$__fish_data_dir",
"$__fish_config_dir"
],
"defaultValue": [
"$__fish_config_dir"
],
"valueType": "array"
},
{
"name": "fish_lsp_diagnostic_disable_error_codes",
"description": "The diagnostics error codes to disable from the fish-lsp's diagnostics.",
"shortDescription": "diagnostic codes to disable",
"exactMatchOptions": true,
"options": [
1001,
1002,
1003,
1004,
1005,
2001,
2002,
2003,
2004,
3001,
3002,
3003,
4001,
4002,
4003,
4004,
4005,
4006,
4007,
4008,
5001,
5555,
6001,
7001,
8001,
9999
],
"defaultValue": [],
"valueType": "array"
},
{
"name": "fish_lsp_max_diagnostics",
"description": "The maximum number of diagnostics to return per file.\n\nUsing value `0` means unlimited diagnostics.\n\nTo entirely disable diagnostics use `fish_lsp_disabled_handlers`",
"shortDescription": "maximum number of diagnostics to return per file",
"exactMatchOptions": false,
"options": [
0,
10,
25,
50,
100,
250
],
"defaultValue": 0,
"valueType": "number"
},
{
"name": "fish_lsp_enable_experimental_diagnostics",
"description": "Enables the experimental diagnostics feature, using `fish --no-execute`.\n\nThis feature will enable the diagnostic error code 9999 (disabled by default).",
"shortDescription": "enable fish-lsp's experimental diagnostics",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": false,
"valueType": "boolean"
},
{
"name": "fish_lsp_strict_conditional_command_warnings",
"description": "Diagnostic `3002` includes/excludes conditionally chained commands to explicitly check existence.\n\nENABLED EXAMPLE: `command -q ls && command ls || echo 'no ls'`\n\nDISABLED EXAMPLE: `command ls || echo 'no ls'`",
"shortDescription": "diagnostic `3002` show warnings for syntax like `command ls || echo 'no ls'`",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": false,
"valueType": "boolean"
},
{
"name": "fish_lsp_prefer_builtin_fish_commands",
"description": "Show diagnostic `2004` which warns the user when they are using a recognized external command that can be replaced by an equivalent fish builtin command.",
"shortDescription": "prefer built-in fish commands over external shell commands",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": false,
"valueType": "boolean"
},
{
"name": "fish_lsp_allow_fish_wrapper_functions",
"description": "Show warnings when `alias`, `export`, etc... are used instead of their equivalent fish builtin commands.\n\nSome commands will provide quick-fixes to convert this diagnostic to its equivalent fish command.\n\nDiagnostic `2002` is shown when this setting is false, and hidden when true.",
"shortDescription": "prefer the user to use primitive fish commands instead of wrapper utilities common in other shells",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": true,
"valueType": "boolean"
},
{
"name": "fish_lsp_require_autoloaded_functions_to_have_description",
"description": "Show warning diagnostic `4008` when an autoloaded function definition does not have a description `function -d/--description '...'; end;`",
"shortDescription": "enable showing diagnostic `4008`",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": true,
"valueType": "boolean"
},
{
"name": "fish_lsp_max_background_files",
"description": "The maximum number of background files to read into buffer on startup.",
"shortDescription": "maximum number of files to analyze in the background on startup",
"exactMatchOptions": false,
"options": [
100,
250,
500,
1000,
5000,
10000
],
"defaultValue": 10000,
"valueType": "number"
},
{
"name": "fish_lsp_show_client_popups",
"description": "Should the client receive pop-up window notification requests from the fish-lsp server?",
"shortDescription": "send `connection/window/*` requests in the server",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": false,
"valueType": "boolean"
},
{
"name": "fish_lsp_single_workspace_support",
"description": "Try to limit the fish-lsp's workspace searching to only the current workspace open.",
"shortDescription": "limit workspace searching to only the current workspace",
"exactMatchOptions": true,
"options": [
true,
false
],
"defaultValue": false,
"valueType": "boolean"
},
{
"name": "fish_lsp_ignore_paths",
"description": "Glob paths to never search when indexing their parent folder",
"shortDescription": "paths to ignore when indexing",
"exactMatchOptions": false,
"options": [
"**/.git/**",
"**/node_modules/**",
"**/vendor/**",
"**/__pycache__/**",
"**/docker/**",
"**/containerized/**",
"**/*.log",
"**/tmp/**"
],
"defaultValue": [
"**/.git/**",
"**/node_modules/**",
"**/containerized/**",
"**/docker/**"
],
"valueType": "array"
},
{
"name": "fish_lsp_max_workspace_depth",
"description": "The maximum depth for the lsp to search when starting up.",
"shortDescription": "maximum directory depth to search for fish files on startup",
"exactMatchOptions": false,
"options": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
15,
20
],
"defaultValue": 5,
"valueType": "number"
},
{
"name": "fish_lsp_fish_path",
"description": "A path to the fish executable to use exposing fish binary to use in server's spawned child_processes.\n\nTypically, this is used in the language-client's `FishServer.initialize(connection, InitializeParams.initializationOptions)`, NOT as an environment variable",
"shortDescription": "specific binary to use for executing 'fish' commands inside server",
"exactMatchOptions": false,
"options": [
"fish",
"/usr/bin/fish",
"/usr/.local/bin/fish",
"~/.local/bin/fish"
],
"defaultValue": "fish",
"valueType": "string"
}
]
================================================
FILE: src/snippets/functions.json
================================================
[
{
"name": "__fish_any_arg_in",
"file": "$__fish_data_dir/functions/__fish_any_arg_in.fish",
"flags": []
},
{
"name": "__fish_anyeditor",
"file": "$__fish_data_dir/functions/__fish_anyeditor.fish",
"flags": []
},
{
"name": "__fish_anypager",
"file": "$__fish_data_dir/functions/__fish_anypager.fish",
"flags": [
"--description Print a pager to use"
],
"description": "Print a pager to use"
},
{
"name": "__fish_anypython",
"file": "$__fish_data_dir/functions/__fish_anypython.fish",
"flags": []
},
{
"name": "__fish_append",
"file": "$__fish_data_dir/functions/__fish_append.fish",
"flags": [
"-d Internal completion function for appending string to the commandline",
"--argument-names sep"
],
"description": "Internal completion function for appending string to the commandline"
},
{
"name": "__fish_apropos",
"file": "$__fish_data_dir/functions/__fish_apropos.fish",
"flags": []
},
{
"name": "__fish_argcomplete_complete",
"file": "$__fish_data_dir/functions/__fish_argcomplete_complete.fish",
"flags": []
},
{
"name": "__fish_cache_put",
"file": "$__fish_data_dir/functions/__fish_cache_put.fish",
"flags": []
},
{
"name": "__fish_cache_sourced_completions",
"file": "$__fish_data_dir/functions/__fish_cache_sourced_completions.fish",
"flags": []
},
{
"name": "__fish_cached",
"file": "$__fish_data_dir/functions/__fish_cached.fish",
"flags": [
"--description Cache the command output for a given amount of time"
],
"description": "Cache the command output for a given amount of time"
},
{
"name": "__fish_cancel_commandline",
"file": "$__fish_data_dir/functions/__fish_cancel_commandline.fish",
"flags": []
},
{
"name": "__fish_change_key_bindings",
"file": "$__fish_data_dir/functions/__fish_change_key_bindings.fish",
"flags": [
"--argument-names bindings"
]
},
{
"name": "__fish_cmd__complete_args",
"file": "$__fish_data_dir/functions/__fish_cmd__complete_args.fish",
"flags": []
},
{
"name": "__fish_commandline_is_singlequoted",
"file": "$__fish_data_dir/functions/__fish_commandline_is_singlequoted.fish",
"flags": [
"--description Return 0 if the current token has an open single-quote"
],
"description": "Return 0 if the current token has an open single-quote"
},
{
"name": "__fish_complete_atool_archive_contents",
"file": "$__fish_data_dir/functions/__fish_complete_atool_archive_contents.fish",
"flags": [
"--description List archive contents"
],
"description": "List archive contents"
},
{
"name": "__fish_complete_bittorrent",
"file": "$__fish_data_dir/functions/__fish_complete_bittorrent.fish",
"flags": []
},
{
"name": "__fish_complete_blockdevice",
"file": "$__fish_data_dir/functions/__fish_complete_blockdevice.fish",
"flags": []
},
{
"name": "__fish_complete_cd",
"file": "$__fish_data_dir/functions/__fish_complete_cd.fish",
"flags": [
"-d Completions for the cd command"
],
"description": "Completions for the cd command"
},
{
"name": "__fish_complete_clang",
"file": "$__fish_data_dir/functions/__fish_complete_clang.fish",
"flags": []
},
{
"name": "__fish_complete_command",
"file": "$__fish_data_dir/functions/__fish_complete_command.fish",
"flags": [
"--description Complete using all available commands"
],
"description": "Complete using all available commands"
},
{
"name": "__fish_complete_convert_options",
"file": "$__fish_data_dir/functions/__fish_complete_convert_options.fish",
"flags": [
"--description Complete Convert options",
"--argument-names what"
],
"description": "Complete Convert options"
},
{
"name": "__fish_complete_directories",
"file": "$__fish_data_dir/functions/__fish_complete_directories.fish",
"flags": [
"-d Complete directory prefixes",
"--argument-names comp"
],
"description": "Complete directory prefixes"
},
{
"name": "__fish_complete_docutils",
"file": "$__fish_data_dir/functions/__fish_complete_docutils.fish",
"flags": [
"-a cmd"
]
},
{
"name": "__fish_complete_freedesktop_icons",
"file": "$__fish_data_dir/functions/__fish_complete_freedesktop_icons.fish",
"flags": [
"-d List installed icon names according to `https://specifications.freedesktop.org/icon-theme-spec/0.13/`"
],
"description": "List installed icon names according to `https://specifications.freedesktop.org/icon-theme-spec/0.13/`"
},
{
"name": "__fish_complete_ftp",
"file": "$__fish_data_dir/functions/__fish_complete_ftp.fish",
"flags": [
"--argument-names ftp"
]
},
{
"name": "__fish_complete_gpg",
"file": "$__fish_data_dir/functions/__fish_complete_gpg.fish",
"flags": [
"-a __fish_complete_gpg_command"
]
},
{
"name": "__fish_complete_gpg_key_id",
"file": "$__fish_data_dir/functions/__fish_complete_gpg_key_id.fish",
"flags": [
"-d Complete using gpg key ids",
"-a __fish_complete_gpg_command"
],
"description": "Complete using gpg key ids"
},
{
"name": "__fish_complete_gpg_user_id",
"file": "$__fish_data_dir/functions/__fish_complete_gpg_user_id.fish",
"flags": [
"-d Complete using gpg user ids",
"-a __fish_complete_gpg_command"
],
"description": "Complete using gpg user ids"
},
{
"name": "__fish_complete_group_ids",
"file": "$__fish_data_dir/functions/__fish_complete_group_ids.fish",
"flags": [
"--description Complete group IDs with group name as description"
],
"description": "Complete group IDs with group name as description"
},
{
"name": "__fish_complete_groups",
"file": "$__fish_data_dir/functions/__fish_complete_groups.fish",
"flags": [
"--description Print a list of local groups, with group members as the description"
],
"description": "Print a list of local groups, with group members as the description"
},
{
"name": "__fish_complete_job_pids",
"file": "$__fish_data_dir/functions/__fish_complete_job_pids.fish",
"flags": [
"--description Print a list of job PIDs and their commands"
],
"description": "Print a list of job PIDs and their commands"
},
{
"name": "__fish_complete_list",
"file": "$__fish_data_dir/functions/__fish_complete_list.fish",
"flags": [
"--argument-names div"
]
},
{
"name": "__fish_complete_lpr",
"file": "$__fish_data_dir/functions/__fish_complete_lpr.fish",
"flags": [
"-d Complete lpr common options",
"--argument-names cmd"
],
"description": "Complete lpr common options"
},
{
"name": "__fish_complete_lpr_option",
"file": "$__fish_data_dir/functions/__fish_complete_lpr_option.fish",
"flags": [
"--description Complete lpr option"
],
"description": "Complete lpr option"
},
{
"name": "__fish_complete_magick",
"file": "$__fish_data_dir/functions/__fish_complete_magick.fish",
"flags": []
},
{
"name": "__fish_complete_man",
"file": "$__fish_data_dir/functions/__fish_complete_man.fish",
"flags": []
},
{
"name": "__fish_complete_netcat",
"file": "$__fish_data_dir/functions/__fish_complete_netcat.fish",
"flags": []
},
{
"name": "__fish_complete_path",
"file": "$__fish_data_dir/functions/__fish_complete_path.fish",
"flags": [
"--description Complete using path"
],
"description": "Complete using path"
},
{
"name": "__fish_complete_pg_database",
"file": "$__fish_data_dir/functions/__fish_complete_pg_database.fish",
"flags": []
},
{
"name": "__fish_complete_pg_user",
"file": "$__fish_data_dir/functions/__fish_complete_pg_user.fish",
"flags": []
},
{
"name": "__fish_complete_pgrep",
"file": "$__fish_data_dir/functions/__fish_complete_pgrep.fish",
"flags": [
"-d Complete pgrep/pkill",
"--argument-names cmd"
],
"description": "Complete pgrep/pkill"
},
{
"name": "__fish_complete_pids",
"file": "$__fish_data_dir/functions/__fish_complete_pids.fish",
"flags": [
"-d Print a list of process identifiers along with brief descriptions"
],
"description": "Print a list of process identifiers along with brief descriptions"
},
{
"name": "__fish_complete_ppp_peer",
"file": "$__fish_data_dir/functions/__fish_complete_ppp_peer.fish",
"flags": [
"--description Complete isp name for pon/poff"
],
"description": "Complete isp name for pon/poff"
},
{
"name": "__fish_complete_proc",
"file": "$__fish_data_dir/functions/__fish_complete_proc.fish",
"flags": []
},
{
"name": "__fish_complete_ssh",
"file": "$__fish_data_dir/functions/__fish_complete_ssh.fish",
"flags": [
"-d common completions for ssh commands",
"--argument-names command"
],
"description": "common completions for ssh commands"
},
{
"name": "__fish_complete_subcommand",
"file": "$__fish_data_dir/functions/__fish_complete_subcommand.fish",
"flags": [
"-d Complete subcommand",
"--no-scope-shadowing"
],
"description": "Complete subcommand"
},
{
"name": "__fish_complete_suffix",
"file": "$__fish_data_dir/functions/__fish_complete_suffix.fish",
"flags": [
"-d Complete using files"
],
"description": "Complete using files"
},
{
"name": "__fish_complete_user_at_hosts",
"file": "$__fish_data_dir/functions/__fish_complete_user_at_hosts.fish",
"flags": [
"-d Print list host-names with user@"
],
"description": "Print list host-names with user@"
},
{
"name": "__fish_complete_user_ids",
"file": "$__fish_data_dir/functions/__fish_complete_user_ids.fish",
"flags": [
"--description Complete user IDs with user name as description"
],
"description": "Complete user IDs with user name as description"
},
{
"name": "__fish_complete_users",
"file": "$__fish_data_dir/functions/__fish_complete_users.fish",
"flags": [
"--description Print a list of local users, with the real user name as a description"
],
"description": "Print a list of local users, with the real user name as a description"
},
{
"name": "__fish_complete_zfs_mountpoint_properties",
"file": "$__fish_data_dir/functions/__fish_complete_zfs_mountpoint_properties.fish",
"flags": []
},
{
"name": "__fish_complete_zfs_pools",
"file": "$__fish_data_dir/functions/__fish_complete_zfs_pools.fish",
"flags": [
"-d Completes with available ZFS pools"
],
"description": "Completes with available ZFS pools"
},
{
"name": "__fish_complete_zfs_ro_properties",
"file": "$__fish_data_dir/functions/__fish_complete_zfs_ro_properties.fish",
"flags": []
},
{
"name": "__fish_complete_zfs_rw_properties",
"file": "$__fish_data_dir/functions/__fish_complete_zfs_rw_properties.fish",
"flags": []
},
{
"name": "__fish_complete_zfs_write_once_properties",
"file": "$__fish_data_dir/functions/__fish_complete_zfs_write_once_properties.fish",
"flags": []
},
{
"name": "__fish_concat_completions",
"file": "$__fish_data_dir/functions/__fish_concat_completions.fish",
"flags": [
"-d Generate completions that are specified as comma-separated values from stdin source"
],
"description": "Generate completions that are specified as comma-separated values from stdin source"
},
{
"name": "__fish_config_interactive",
"file": "$__fish_data_dir/functions/__fish_config_interactive.fish",
"flags": [
"-d Initializations that should be performed when entering interactive mode"
],
"description": "Initializations that should be performed when entering interactive mode"
},
{
"name": "__fish_contains_opt",
"file": "$__fish_data_dir/functions/__fish_contains_opt.fish",
"flags": [
"-d Checks if a specific option has been given in the current commandline"
],
"description": "Checks if a specific option has been given in the current commandline"
},
{
"name": "__fish_crux_packages",
"file": "$__fish_data_dir/functions/__fish_crux_packages.fish",
"flags": [
"-d Obtain a list of installed packages"
],
"description": "Obtain a list of installed packages"
},
{
"name": "__fish_cursor_konsole",
"file": "$__fish_data_dir/functions/__fish_cursor_konsole.fish",
"flags": [
"-d Set cursor (konsole)"
],
"description": "Set cursor (konsole)"
},
{
"name": "__fish_cursor_xterm",
"file": "$__fish_data_dir/functions/__fish_cursor_xterm.fish",
"flags": [
"-d Set cursor (xterm)"
],
"description": "Set cursor (xterm)"
},
{
"name": "__fish_default_command_not_found_handler",
"file": "$__fish_data_dir/functions/fish_command_not_found.fish",
"flags": []
},
{
"name": "__fish_describe_command",
"file": "$__fish_data_dir/functions/__fish_describe_command.fish",
"flags": []
},
{
"name": "__fish_echo",
"file": "$__fish_data_dir/functions/__fish_echo.fish",
"flags": [
"--inherit-variable erase_line",
"--description run the given command after the current commandline and redraw the prompt"
],
"description": "run the given command after the current commandline and redraw the prompt"
},
{
"name": "__fish_edit_command_if_at_cursor",
"file": "$__fish_data_dir/functions/__fish_edit_command_if_at_cursor.fish",
"flags": [
"--description If cursor is at the command token, edit the command source file"
],
"description": "If cursor is at the command token, edit the command source file"
},
{
"name": "__fish_first_token",
"file": "$__fish_data_dir/functions/__fish_first_token.fish",
"flags": []
},
{
"name": "__fish_git_prompt",
"file": "$__fish_data_dir/functions/__fish_git_prompt.fish",
"flags": []
},
{
"name": "__fish_git_prompt_show_upstream",
"file": "$__fish_data_dir/functions/fish_git_prompt.fish",
"flags": [
"--description Helper function for fish_git_prompt"
],
"description": "Helper function for fish_git_prompt"
},
{
"name": "__fish_gnu_complete",
"file": "$__fish_data_dir/functions/__fish_gnu_complete.fish",
"flags": [
"-d Wrapper for the complete built-in. Skips the long completions on non-GNU systems"
],
"description": "Wrapper for the complete built-in. Skips the long completions on non-GNU systems"
},
{
"name": "__fish_hg_prompt",
"file": "$__fish_data_dir/functions/__fish_hg_prompt.fish",
"flags": []
},
{
"name": "__fish_indent",
"file": "$__fish_data_dir/functions/__fish_indent.fish",
"flags": [
"--wraps fish_indent",
"--inherit-variable dir"
]
},
{
"name": "__fish_is_first_arg",
"file": "$__fish_data_dir/functions/__fish_is_first_arg.fish",
"flags": []
},
{
"name": "__fish_is_first_token",
"file": "$__fish_data_dir/functions/__fish_is_first_token.fish",
"flags": [
"-d Test if no non-switch argument has been specified yet"
],
"description": "Test if no non-switch argument has been specified yet"
},
{
"name": "__fish_is_git_repository",
"file": "$__fish_data_dir/functions/__fish_is_git_repository.fish",
"flags": [
"--description Check if the current directory is a git repository"
],
"description": "Check if the current directory is a git repository"
},
{
"name": "__fish_is_nth_token",
"file": "$__fish_data_dir/functions/__fish_is_nth_token.fish",
"flags": [
"--description Test if current token is the Nth (ignoring command and switches/flags)",
"--argument-names n"
],
"description": "Test if current token is the Nth (ignoring command and switches/flags)"
},
{
"name": "__fish_is_switch",
"file": "$__fish_data_dir/functions/__fish_is_switch.fish",
"flags": []
},
{
"name": "__fish_is_token_n",
"file": "$__fish_data_dir/functions/__fish_is_token_n.fish",
"flags": [
"--description Test if current token is on Nth place",
"--argument-names n"
],
"description": "Test if current token is on Nth place"
},
{
"name": "__fish_is_zfs_feature_enabled",
"file": "$__fish_data_dir/functions/__fish_is_zfs_feature_enabled.fish",
"flags": []
},
{
"name": "__fish_list_current_token",
"file": "$__fish_data_dir/functions/__fish_list_current_token.fish",
"flags": [
"-d List contents of token under the cursor if it is a directory, otherwise list the contents of the current directory"
],
"description": "List contents of token under the cursor if it is a directory, otherwise list the contents of the current directory"
},
{
"name": "__fish_make_cache_dir",
"file": "$__fish_data_dir/functions/__fish_make_cache_dir.fish",
"flags": [
"--description Create and return XDG_CACHE_HOME"
],
"description": "Create and return XDG_CACHE_HOME"
},
{
"name": "__fish_make_completion_signals",
"file": "$__fish_data_dir/functions/__fish_make_completion_signals.fish",
"flags": [
"--description Make list of kill signals for completion"
],
"description": "Make list of kill signals for completion"
},
{
"name": "__fish_man_page",
"file": "$__fish_data_dir/functions/__fish_man_page.fish",
"flags": []
},
{
"name": "__fish_md5",
"file": "$__fish_data_dir/functions/__fish_md5.fish",
"flags": []
},
{
"name": "__fish_mktemp_relative",
"file": "$__fish_data_dir/functions/__fish_mktemp_relative.fish",
"flags": []
},
{
"name": "__fish_move_last",
"file": "$__fish_data_dir/functions/__fish_move_last.fish",
"flags": [
"-d Move the last element of a directory history from src to dest"
],
"description": "Move the last element of a directory history from src to dest"
},
{
"name": "__fish_mysql_query",
"file": "$__fish_data_dir/functions/__fish_complete_mysql.fish",
"flags": [
"-a query"
]
},
{
"name": "__fish_no_arguments",
"file": "$__fish_data_dir/functions/__fish_no_arguments.fish",
"flags": [
"-d Internal fish function"
],
"description": "Internal fish function"
},
{
"name": "__fish_not_contain_opt",
"file": "$__fish_data_dir/functions/__fish_not_contain_opt.fish",
"flags": [
"-d Checks that a specific option is not in the current command line"
],
"description": "Checks that a specific option is not in the current command line"
},
{
"name": "__fish_nth_token",
"file": "$__fish_data_dir/functions/__fish_nth_token.fish",
"flags": [
"--description Prints the Nth token (ignoring command and switches/flags)",
"--argument-names n"
],
"description": "Prints the Nth token (ignoring command and switches/flags)"
},
{
"name": "__fish_number_of_cmd_args_wo_opts",
"file": "$__fish_data_dir/functions/__fish_number_of_cmd_args_wo_opts.fish",
"flags": []
},
{
"name": "__fish_opt_validate_args",
"file": "$__fish_data_dir/functions/fish_opt.fish",
"flags": [
"--no-scope-shadowing"
]
},
{
"name": "__fish_paginate",
"file": "$__fish_data_dir/functions/__fish_paginate.fish",
"flags": [
"-d Paginate the current command using the users default pager"
],
"description": "Paginate the current command using the users default pager"
},
{
"name": "__fish_parent_directories",
"file": "$__fish_data_dir/functions/__fish_parent_directories.fish",
"flags": []
},
{
"name": "__fish_paste",
"file": "$__fish_data_dir/functions/__fish_paste.fish",
"flags": []
},
{
"name": "__fish_prepend_sudo",
"file": "$__fish_data_dir/functions/__fish_prepend_sudo.fish",
"flags": [
"-d DEPRECATED: use fish_commandline_prepend instead. Prepend 'sudo ' to the beginning of the current commandline"
],
"description": "DEPRECATED: use fish_commandline_prepend instead. Prepend 'sudo ' to the beginning of the current commandline"
},
{
"name": "__fish_prev_arg_in",
"file": "$__fish_data_dir/functions/__fish_prev_arg_in.fish",
"flags": []
},
{
"name": "__fish_preview_current_file",
"file": "$__fish_data_dir/functions/__fish_preview_current_file.fish",
"flags": [
"--description Open the file at the cursor in a pager"
],
"description": "Open the file at the cursor in a pager"
},
{
"name": "__fish_print_addresses",
"file": "$__fish_data_dir/functions/__fish_print_addresses.fish",
"flags": [
"--description List own network addresses with interface as description"
],
"description": "List own network addresses with interface as description"
},
{
"name": "__fish_print_apt_packages",
"file": "$__fish_data_dir/functions/__fish_print_apt_packages.fish",
"flags": []
},
{
"name": "__fish_print_cmd_args",
"file": "$__fish_data_dir/functions/__fish_print_cmd_args.fish",
"flags": []
},
{
"name": "__fish_print_cmd_args_without_options",
"file": "$__fish_data_dir/functions/__fish_print_cmd_args_without_options.fish",
"flags": []
},
{
"name": "__fish_print_commands",
"file": "$__fish_data_dir/functions/__fish_print_commands.fish",
"flags": [
"--description Print a list of documented fish commands"
],
"description": "Print a list of documented fish commands"
},
{
"name": "__fish_print_debian_apache_confs",
"file": "$__fish_data_dir/functions/__fish_print_debian_apache_confs.fish",
"flags": []
},
{
"name": "__fish_print_debian_apache_mods",
"file": "$__fish_data_dir/functions/__fish_print_debian_apache_mods.fish",
"flags": []
},
{
"name": "__fish_print_debian_apache_sites",
"file": "$__fish_data_dir/functions/__fish_print_debian_apache_sites.fish",
"flags": []
},
{
"name": "__fish_print_encodings",
"file": "$__fish_data_dir/functions/__fish_print_encodings.fish",
"flags": [
"-d Complete using available character encodings"
],
"description": "Complete using available character encodings"
},
{
"name": "__fish_print_eopkg_packages",
"file": "$__fish_data_dir/functions/__fish_print_eopkg_packages.fish",
"flags": []
},
{
"name": "__fish_print_filesystems",
"file": "$__fish_data_dir/functions/__fish_print_filesystems.fish",
"flags": [
"-d Print a list of all known filesystem types"
],
"description": "Print a list of all known filesystem types"
},
{
"name": "__fish_print_gpg_algo",
"file": "$__fish_data_dir/functions/__fish_print_gpg_algo.fish",
"flags": [
"-d Complete using all algorithms of the type specified in argv[2] supported by gpg. argv[2] is a regexp",
"-a __fish_complete_gpg_command"
],
"description": "Complete using all algorithms of the type specified in argv[2] supported by gpg. argv[2] is a regexp"
},
{
"name": "__fish_print_groups",
"file": "$__fish_data_dir/functions/__fish_print_groups.fish",
"flags": [
"--description Print a list of local groups"
],
"description": "Print a list of local groups"
},
{
"name": "__fish_print_help",
"file": "$__fish_data_dir/functions/__fish_print_help.fish",
"flags": [
"--description Print help for the specified fish function or builtin"
],
"description": "Print help for the specified fish function or builtin"
},
{
"name": "__fish_print_hostnames",
"file": "$__fish_data_dir/functions/__fish_print_hostnames.fish",
"flags": [
"-d Print a list of known hostnames"
],
"description": "Print a list of known hostnames"
},
{
"name": "__fish_print_interfaces",
"file": "$__fish_data_dir/functions/__fish_print_interfaces.fish",
"flags": [
"--description Print a list of known network interfaces"
],
"description": "Print a list of known network interfaces"
},
{
"name": "__fish_print_lpr_options",
"file": "$__fish_data_dir/functions/__fish_print_lpr_options.fish",
"flags": [
"--description Print lpr options"
],
"description": "Print lpr options"
},
{
"name": "__fish_print_lpr_printers",
"file": "$__fish_data_dir/functions/__fish_print_lpr_printers.fish",
"flags": [
"--description Print lpr printers"
],
"description": "Print lpr printers"
},
{
"name": "__fish_print_modules",
"file": "$__fish_data_dir/functions/__fish_print_modules.fish",
"flags": []
},
{
"name": "__fish_print_mounted",
"file": "$__fish_data_dir/functions/__fish_print_mounted.fish",
"flags": [
"--description Print mounted devices"
],
"description": "Print mounted devices"
},
{
"name": "__fish_print_opkg_packages",
"file": "$__fish_data_dir/functions/__fish_print_opkg_packages.fish",
"flags": []
},
{
"name": "__fish_print_packages",
"file": "$__fish_data_dir/functions/__fish_print_packages.fish",
"flags": []
},
{
"name": "__fish_print_pacman_packages",
"file": "$__fish_data_dir/functions/__fish_print_pacman_packages.fish",
"flags": []
},
{
"name": "__fish_print_pacman_repos",
"file": "$__fish_data_dir/functions/__fish_print_pacman_repos.fish",
"flags": [
"--description Print the repositories configured for arch's pacman package manager"
],
"description": "Print the repositories configured for arch's pacman package manager"
},
{
"name": "__fish_print_pipestatus",
"file": "$__fish_data_dir/functions/__fish_print_pipestatus.fish",
"flags": [
"--description Print pipestatus for prompt"
],
"description": "Print pipestatus for prompt"
},
{
"name": "__fish_print_pkg_add_packages",
"file": "$__fish_data_dir/functions/__fish_print_pkg_add_packages.fish",
"flags": []
},
{
"name": "__fish_print_pkg_packages",
"file": "$__fish_data_dir/functions/__fish_print_pkg_packages.fish",
"flags": []
},
{
"name": "__fish_print_port_packages",
"file": "$__fish_data_dir/functions/__fish_print_port_packages.fish",
"flags": []
},
{
"name": "__fish_print_portage_available_pkgs",
"file": "$__fish_data_dir/functions/__fish_print_portage_available_pkgs.fish",
"flags": [
"--description Print all available packages"
],
"description": "Print all available packages"
},
{
"name": "__fish_print_portage_installed_pkgs",
"file": "$__fish_data_dir/functions/__fish_print_portage_installed_pkgs.fish",
"flags": [
"--description Print all installed packages (non-deduplicated)"
],
"description": "Print all installed packages (non-deduplicated)"
},
{
"name": "__fish_print_portage_packages",
"file": "$__fish_data_dir/functions/__fish_print_portage_packages.fish",
"flags": []
},
{
"name": "__fish_print_portage_repository_paths",
"file": "$__fish_data_dir/functions/__fish_print_portage_repository_paths.fish",
"flags": [
"--description Print the paths of all configured repositories"
],
"description": "Print the paths of all configured repositories"
},
{
"name": "__fish_print_rpm_packages",
"file": "$__fish_data_dir/functions/__fish_print_rpm_packages.fish",
"flags": []
},
{
"name": "__fish_print_service_names",
"file": "$__fish_data_dir/functions/__fish_print_service_names.fish",
"flags": [
"-d All services known to the system"
],
"description": "All services known to the system"
},
{
"name": "__fish_print_svn_rev",
"file": "$__fish_data_dir/functions/__fish_print_svn_rev.fish",
"flags": [
"--description Print svn revisions"
],
"description": "Print svn revisions"
},
{
"name": "__fish_print_user_ids",
"file": "$__fish_data_dir/functions/__fish_complete_mount_opts.fish",
"flags": []
},
{
"name": "__fish_print_users",
"file": "$__fish_data_dir/functions/__fish_print_users.fish",
"flags": [
"--description Print a list of local users"
],
"description": "Print a list of local users"
},
{
"name": "__fish_print_VBox_vms",
"file": "$__fish_data_dir/functions/__fish_print_VBox_vms.fish",
"flags": []
},
{
"name": "__fish_print_windows_drives",
"file": "$__fish_data_dir/functions/__fish_print_windows_drives.fish",
"flags": [
"--description Print Windows drives"
],
"description": "Print Windows drives"
},
{
"name": "__fish_print_windows_users",
"file": "$__fish_data_dir/functions/__fish_print_windows_users.fish",
"flags": [
"--description Print Windows user names"
],
"description": "Print Windows user names"
},
{
"name": "__fish_print_xbps_packages",
"file": "$__fish_data_dir/functions/__fish_print_xbps_packages.fish",
"flags": []
},
{
"name": "__fish_print_xdg_applications_directories",
"file": "$__fish_data_dir/functions/__fish_print_xdg_applications_directories.fish",
"flags": [
"--description Print directories where desktop files are stored"
],
"description": "Print directories where desktop files are stored"
},
{
"name": "__fish_print_xdg_mimetypes",
"file": "$__fish_data_dir/functions/__fish_print_xdg_mimetypes.fish",
"flags": [
"--description Print XDG mime types"
],
"description": "Print XDG mime types"
},
{
"name": "__fish_print_xwindows",
"file": "$__fish_data_dir/functions/__fish_print_xwindows.fish",
"flags": [
"--description Print X windows"
],
"description": "Print X windows"
},
{
"name": "__fish_print_zfs_snapshots",
"file": "$__fish_data_dir/functions/__fish_print_zfs_snapshots.fish",
"flags": [
"-d Lists ZFS snapshots"
],
"description": "Lists ZFS snapshots"
},
{
"name": "__fish_protontricks_complete_appid",
"file": "$__fish_data_dir/functions/__fish_protontricks_complete_appid.fish",
"flags": []
},
{
"name": "__fish_ps",
"file": "$__fish_data_dir/functions/__fish_ps.fish",
"flags": []
},
{
"name": "__fish_pwd",
"file": "$__fish_data_dir/functions/__fish_pwd.fish",
"flags": [
"--description Show current path"
],
"description": "Show current path"
},
{
"name": "__fish_reg__complete_keys",
"file": "$__fish_data_dir/functions/__fish_reg__complete_keys.fish",
"flags": []
},
{
"name": "__fish_seen_argument",
"file": "$__fish_data_dir/functions/__fish_seen_argument.fish",
"flags": [
"--description Check whether argument is used"
],
"description": "Check whether argument is used"
},
{
"name": "__fish_seen_subcommand_from",
"file": "$__fish_data_dir/functions/__fish_seen_subcommand_from.fish",
"flags": []
},
{
"name": "__fish_set_locale",
"file": "$__fish_data_dir/functions/__fish_set_locale.fish",
"flags": []
},
{
"name": "__fish_shared_key_bindings",
"file": "$__fish_data_dir/functions/__fish_shared_key_bindings.fish",
"flags": [
"-d Bindings shared between emacs and vi mode"
],
"description": "Bindings shared between emacs and vi mode"
},
{
"name": "__fish_should_complete_switches",
"file": "$__fish_data_dir/functions/__fish_should_complete_switches.fish",
"flags": []
},
{
"name": "__fish_svn_prompt",
"file": "$__fish_data_dir/functions/__fish_svn_prompt.fish",
"flags": []
},
{
"name": "__fish_svn_prompt_parse_status",
"file": "$__fish_data_dir/functions/fish_svn_prompt.fish",
"flags": [
"--description helper function that does pretty formatting on svn status"
],
"description": "helper function that does pretty formatting on svn status"
},
{
"name": "__fish_systemctl",
"file": "$__fish_data_dir/functions/__fish_systemctl.fish",
"flags": [
"--description Call systemctl with some options from the current commandline"
],
"description": "Call systemctl with some options from the current commandline"
},
{
"name": "__fish_systemctl_services",
"file": "$__fish_data_dir/functions/__fish_systemctl_services.fish",
"flags": []
},
{
"name": "__fish_systemd_machine_images",
"file": "$__fish_data_dir/functions/__fish_systemd_machine_images.fish",
"flags": []
},
{
"name": "__fish_systemd_machines",
"file": "$__fish_data_dir/functions/__fish_systemd_machines.fish",
"flags": []
},
{
"name": "__fish_toggle_comment_commandline",
"file": "$__fish_data_dir/functions/__fish_toggle_comment_commandline.fish",
"flags": [
"--description Comment/uncomment the current command"
],
"description": "Comment/uncomment the current command"
},
{
"name": "__fish_tokenizer_state",
"file": "$__fish_data_dir/functions/__fish_tokenizer_state.fish",
"flags": [
"--description Print the state of the tokenizer at the end of the given string"
],
"description": "Print the state of the tokenizer at the end of the given string"
},
{
"name": "__fish_umask_add",
"file": "$__fish_data_dir/functions/umask.fish",
"flags": []
},
{
"name": "__fish_uname",
"file": "$__fish_data_dir/functions/__fish_uname.fish",
"flags": []
},
{
"name": "__fish_use_subcommand",
"file": "$__fish_data_dir/functions/__fish_use_subcommand.fish",
"flags": [
"-d Test if a non-switch argument has been given in the current commandline"
],
"description": "Test if a non-switch argument has been given in the current commandline"
},
{
"name": "__fish_vcs_prompt",
"file": "$__fish_data_dir/functions/__fish_vcs_prompt.fish",
"flags": []
},
{
"name": "__fish_whatis",
"file": "$__fish_data_dir/functions/__fish_whatis.fish",
"flags": []
},
{
"name": "__fish_whatis_current_token",
"file": "$__fish_data_dir/functions/__fish_whatis_current_token.fish",
"flags": [
"-d Show man page entries or function description related to the token under the cursor"
],
"description": "Show man page entries or function description related to the token under the cursor"
},
{
"name": "__fish_wireshark_choices",
"file": "$__fish_data_dir/functions/__fish_complete_wireshark.fish",
"flags": []
},
{
"name": "__npm_helper_installed",
"file": "$__fish_data_dir/functions/__fish_npm_helper.fish",
"flags": []
},
{
"name": "__ssh_history_completions",
"file": "$__fish_data_dir/functions/__ssh_history_completions.fish",
"flags": [
"-d Retrieve `user@host` entries from history"
],
"description": "Retrieve `user@host` entries from history"
},
{
"name": "__terlar_git_prompt",
"file": "$__fish_data_dir/functions/__terlar_git_prompt.fish",
"flags": []
},
{
"name": "_validate_int",
"file": "$__fish_data_dir/functions/_validate_int.fish",
"flags": [
"--no-scope-shadowing"
]
},
{
"name": "alias",
"file": "$__fish_data_dir/functions/alias.fish",
"flags": [
"--description Creates a function wrapping a command"
],
"description": "Creates a function wrapping a command"
},
{
"name": "cd",
"file": "$__fish_data_dir/functions/cd.fish",
"flags": [
"--description Change directory"
],
"description": "Change directory"
},
{
"name": "cdh",
"file": "$__fish_data_dir/functions/cdh.fish",
"flags": [
"--description Menu based cd command"
],
"description": "Menu based cd command"
},
{
"name": "contains_seq",
"file": "$__fish_data_dir/functions/contains_seq.fish",
"flags": [
"--description Return true if array contains a sequence"
],
"description": "Return true if array contains a sequence"
},
{
"name": "diff",
"file": "$__fish_data_dir/functions/diff.fish",
"flags": []
},
{
"name": "dirh",
"file": "$__fish_data_dir/functions/dirh.fish",
"flags": [
"--description Print the current directory history (the prev and next lists)"
],
"description": "Print the current directory history (the prev and next lists)"
},
{
"name": "dirs",
"file": "$__fish_data_dir/functions/dirs.fish",
"flags": [
"--description Print directory stack"
],
"description": "Print directory stack"
},
{
"name": "down-or-search",
"file": "$__fish_data_dir/functions/down-or-search.fish",
"flags": [
"-d search forward or move down 1 line"
],
"description": "search forward or move down 1 line"
},
{
"name": "edit_command_buffer",
"file": "$__fish_data_dir/functions/edit_command_buffer.fish",
"flags": [
"--description Edit the command buffer in an external editor"
],
"description": "Edit the command buffer in an external editor"
},
{
"name": "export",
"file": "$__fish_data_dir/functions/export.fish",
"flags": [
"--description Set env variable. Alias for `set -gx` for bash compatibility."
],
"description": "Set env variable. Alias for `set -gx` for bash compatibility."
},
{
"name": "fish_add_path",
"file": "$__fish_data_dir/functions/fish_add_path.fish",
"flags": [
"--description Add paths to the PATH"
],
"description": "Add paths to the PATH"
},
{
"name": "fish_breakpoint_prompt",
"file": "$__fish_data_dir/functions/fish_breakpoint_prompt.fish",
"flags": [
"--description A right prompt to be used when `breakpoint` is executed"
],
"description": "A right prompt to be used when `breakpoint` is executed"
},
{
"name": "fish_clipboard_copy",
"file": "$__fish_data_dir/functions/fish_clipboard_copy.fish",
"flags": []
},
{
"name": "fish_clipboard_paste",
"file": "$__fish_data_dir/functions/fish_clipboard_paste.fish",
"flags": []
},
{
"name": "fish_commandline_append",
"file": "$__fish_data_dir/functions/fish_commandline_append.fish",
"flags": [
"--description Append the given string to the command-line, or remove the suffix if already there"
],
"description": "Append the given string to the command-line, or remove the suffix if already there"
},
{
"name": "fish_commandline_prepend",
"file": "$__fish_data_dir/functions/fish_commandline_prepend.fish",
"flags": [
"--description Prepend the given string to the command-line, or remove the prefix if already there"
],
"description": "Prepend the given string to the command-line, or remove the prefix if already there"
},
{
"name": "fish_config",
"file": "$__fish_data_dir/functions/fish_config.fish",
"flags": [
"--description Launch fish's web based configuration"
],
"description": "Launch fish's web based configuration"
},
{
"name": "fish_default_key_bindings",
"file": "$__fish_data_dir/functions/fish_default_key_bindings.fish",
"flags": [
"-d emacs-like key binds"
],
"description": "emacs-like key binds"
},
{
"name": "fish_default_mode_prompt",
"file": "$__fish_data_dir/functions/fish_default_mode_prompt.fish",
"flags": [
"--description Display vi prompt mode"
],
"description": "Display vi prompt mode"
},
{
"name": "fish_delta",
"file": "$__fish_data_dir/functions/fish_delta.fish",
"flags": []
},
{
"name": "fish_fossil_prompt",
"file": "$__fish_data_dir/functions/fish_fossil_prompt.fish",
"flags": [
"--description Write out the fossil prompt"
],
"description": "Write out the fossil prompt"
},
{
"name": "fish_greeting",
"file": "$__fish_data_dir/functions/fish_greeting.fish",
"flags": []
},
{
"name": "fish_hg_prompt",
"file": "$__fish_data_dir/functions/fish_hg_prompt.fish",
"flags": [
"--description Write out the hg prompt"
],
"description": "Write out the hg prompt"
},
{
"name": "fish_hybrid_key_bindings",
"file": "$__fish_data_dir/functions/fish_hybrid_key_bindings.fish",
"flags": [
"--description Vi-style bindings that inherit emacs-style bindings in all modes"
],
"description": "Vi-style bindings that inherit emacs-style bindings in all modes"
},
{
"name": "fish_is_root_user",
"file": "$__fish_data_dir/functions/fish_is_root_user.fish",
"flags": [
"--description Check if the user is root"
],
"description": "Check if the user is root"
},
{
"name": "fish_jj_prompt",
"file": "$__fish_data_dir/functions/fish_jj_prompt.fish",
"flags": []
},
{
"name": "fish_job_summary",
"file": "$__fish_data_dir/functions/fish_job_summary.fish",
"flags": [
"-a job_id"
]
},
{
"name": "fish_mode_prompt",
"file": "$__fish_data_dir/functions/fish_mode_prompt.fish",
"flags": [
"--description Displays the current mode"
],
"description": "Displays the current mode"
},
{
"name": "fish_print_git_action",
"file": "$__fish_data_dir/functions/fish_print_git_action.fish",
"flags": [
"--argument-names git_dir"
]
},
{
"name": "fish_print_hg_root",
"file": "$__fish_data_dir/functions/fish_print_hg_root.fish",
"flags": []
},
{
"name": "fish_prompt",
"file": "$__fish_data_dir/functions/fish_prompt.fish",
"flags": [
"--description Write out the prompt"
],
"description": "Write out the prompt"
},
{
"name": "fish_status_to_signal",
"file": "$__fish_data_dir/functions/fish_status_to_signal.fish",
"flags": [
"--description Convert exit code to signal name"
],
"description": "Convert exit code to signal name"
},
{
"name": "fish_title",
"file": "$__fish_data_dir/functions/fish_title.fish",
"flags": []
},
{
"name": "fish_update_completions",
"file": "$__fish_data_dir/functions/fish_update_completions.fish",
"flags": [
"--description Update man-page based completions"
],
"description": "Update man-page based completions"
},
{
"name": "fish_vcs_prompt",
"file": "$__fish_data_dir/functions/fish_vcs_prompt.fish",
"flags": [
"--description Print all vcs prompts"
],
"description": "Print all vcs prompts"
},
{
"name": "fish_vi_cursor",
"file": "$__fish_data_dir/functions/fish_vi_cursor.fish",
"flags": [
"-d Set cursor shape for different vi modes"
],
"description": "Set cursor shape for different vi modes"
},
{
"name": "fish_vi_inc_dec",
"file": "$__fish_data_dir/functions/fish_vi_key_bindings.fish",
"flags": [
"--description increment or decrement the number below the cursor"
],
"description": "increment or decrement the number below the cursor"
},
{
"name": "funced",
"file": "$__fish_data_dir/functions/funced.fish",
"flags": [
"--description Edit function definition"
],
"description": "Edit function definition"
},
{
"name": "funcsave",
"file": "$__fish_data_dir/functions/funcsave.fish",
"flags": [
"--description Save the current definition of all specified functions to file"
],
"description": "Save the current definition of all specified functions to file"
},
{
"name": "grep",
"file": "$__fish_data_dir/functions/grep.fish",
"flags": []
},
{
"name": "help",
"file": "$__fish_data_dir/functions/help.fish",
"flags": [
"--description Show help for the fish shell"
],
"description": "Show help for the fish shell"
},
{
"name": "history",
"file": "$__fish_data_dir/functions/history.fish",
"flags": [
"--description display or manipulate interactive command history"
],
"description": "display or manipulate interactive command history"
},
{
"name": "isatty",
"file": "$__fish_data_dir/functions/isatty.fish",
"flags": [
"-d Tests if a file descriptor is a tty"
],
"description": "Tests if a file descriptor is a tty"
},
{
"name": "la",
"file": "$__fish_data_dir/functions/la.fish",
"flags": [
"--wraps ls",
"--description List contents of directory, including hidden files in directory using long format"
],
"description": "List contents of directory, including hidden files in directory using long format"
},
{
"name": "ll",
"file": "$__fish_data_dir/functions/ll.fish",
"flags": [
"--wraps ls",
"--description List contents of directory using long format"
],
"description": "List contents of directory using long format"
},
{
"name": "ls",
"file": "$__fish_data_dir/functions/ls.fish",
"flags": []
},
{
"name": "man",
"file": "$__fish_data_dir/functions/man.fish",
"flags": []
},
{
"name": "N_",
"file": "$__fish_data_dir/functions/N_.fish",
"flags": []
},
{
"name": "nextd",
"file": "$__fish_data_dir/functions/nextd.fish",
"flags": [
"--description Move forward in the directory history"
],
"description": "Move forward in the directory history"
},
{
"name": "nextd-or-forward-token",
"file": "$__fish_data_dir/functions/nextd-or-forward-token.fish",
"flags": [
"--description If commandline is empty, run nextd; else move one argument to the right"
],
"description": "If commandline is empty, run nextd; else move one argument to the right"
},
{
"name": "open",
"file": "$__fish_data_dir/functions/open.fish",
"flags": [
"--description Open file in default application"
],
"description": "Open file in default application"
},
{
"name": "popd",
"file": "$__fish_data_dir/functions/popd.fish",
"flags": [
"--description Pop directory from the stack and cd to it"
],
"description": "Pop directory from the stack and cd to it"
},
{
"name": "prevd",
"file": "$__fish_data_dir/functions/prevd.fish",
"flags": [
"--description Move back in the directory history"
],
"description": "Move back in the directory history"
},
{
"name": "prevd-or-backward-token",
"file": "$__fish_data_dir/functions/prevd-or-backward-token.fish",
"flags": [
"--description If commandline is empty, run prevd; else move one argument to the left"
],
"description": "If commandline is empty, run prevd; else move one argument to the left"
},
{
"name": "prompt_hostname",
"file": "$__fish_data_dir/functions/prompt_hostname.fish",
"flags": [
"--description short hostname for the prompt"
],
"description": "short hostname for the prompt"
},
{
"name": "prompt_login",
"file": "$__fish_data_dir/functions/prompt_login.fish",
"flags": [
"--description display user name for the prompt"
],
"description": "display user name for the prompt"
},
{
"name": "prompt_pwd",
"file": "$__fish_data_dir/functions/prompt_pwd.fish",
"flags": [
"--description short CWD for the prompt"
],
"description": "short CWD for the prompt"
},
{
"name": "psub",
"file": "$__fish_data_dir/functions/psub.fish",
"flags": [
"--description Read from stdin into a file and output the filename. Remove the file when the command that called psub exits."
],
"description": "Read from stdin into a file and output the filename. Remove the file when the command that called psub exits."
},
{
"name": "pushd",
"file": "$__fish_data_dir/functions/pushd.fish",
"flags": [
"--description Push directory to stack"
],
"description": "Push directory to stack"
},
{
"name": "realpath",
"file": "$__fish_data_dir/functions/realpath.fish",
"flags": []
},
{
"name": "seq",
"file": "$__fish_data_dir/functions/seq.fish",
"flags": []
},
{
"name": "setenv",
"file": "$__fish_data_dir/functions/setenv.fish",
"flags": []
},
{
"name": "suspend",
"file": "$__fish_data_dir/functions/suspend.fish",
"flags": [
"--description Suspend the current shell."
],
"description": "Suspend the current shell."
},
{
"name": "trap",
"file": "$__fish_data_dir/functions/trap.fish",
"flags": [
"-d Perform an action when the shell receives a signal"
],
"description": "Perform an action when the shell receives a signal"
},
{
"name": "up-or-search",
"file": "$__fish_data_dir/functions/up-or-search.fish",
"flags": [
"-d Search back or move cursor up 1 line"
],
"description": "Search back or move cursor up 1 line"
},
{
"name": "vared",
"file": "$__fish_data_dir/functions/vared.fish",
"flags": [
"--description Edit variable value"
],
"description": "Edit variable value"
}
]
================================================
FILE: src/snippets/helperCommands.json
================================================
[
{
"name": "_",
"description": "call fish’s translations"
},
{
"name": "abbr",
"description": "manage fish abbreviations"
},
{
"name": "alias",
"description": "create a function"
},
{
"name": "and",
"description": "conditionally execute a command"
},
{
"name": "argparse",
"description": "parse options passed to a fish script or function"
},
{
"name": "begin",
"description": "start a new block of code"
},
{
"name": "bg",
"description": "send jobs to background"
},
{
"name": "bind",
"description": "handle fish key bindings"
},
{
"name": "block",
"description": "temporarily block delivery of events"
},
{
"name": "break",
"description": "stop the current inner loop"
},
{
"name": "breakpoint",
"description": "launch debug mode"
},
{
"name": "builtin",
"description": "run a builtin command"
},
{
"name": "case",
"description": "conditionally execute a block of commands"
},
{
"name": "cd",
"description": "change directory"
},
{
"name": "cdh",
"description": "change to a recently visited directory"
},
{
"name": "command",
"description": "run a program"
},
{
"name": "commandline",
"description": "set or get the current command line buffer"
},
{
"name": "complete",
"description": "edit command-specific tab-completions"
},
{
"name": "contains",
"description": "test if a word is present in a list"
},
{
"name": "continue",
"description": "skip the remainder of the current iteration of the current inner loop"
},
{
"name": "count",
"description": "count the number of elements of a list"
},
{
"name": "dirh",
"description": "print directory history"
},
{
"name": "dirs",
"description": "print directory stack"
},
{
"name": "disown",
"description": "remove a process from the list of jobs"
},
{
"name": "echo",
"description": "display a line of text"
},
{
"name": "else",
"description": "execute command if a condition is not met"
},
{
"name": "emit",
"description": "emit a generic event"
},
{
"name": "end",
"description": "end a block of commands"
},
{
"name": "eval",
"description": "evaluate the specified commands"
},
{
"name": "exec",
"description": "execute command in current process"
},
{
"name": "exit",
"description": "exit the shell"
},
{
"name": "export",
"description": "compatibility function for exporting variables"
},
{
"name": "false",
"description": "return an unsuccessful result"
},
{
"name": "fg",
"description": "bring job to foreground"
},
{
"name": "fish",
"description": "the friendly interactive shell"
},
{
"name": "fish_add_path",
"description": "add to the path"
},
{
"name": "fish_breakpoint_prompt",
"description": "define the prompt when stopped at a breakpoint"
},
{
"name": "fish_clipboard_copy",
"description": "copy text to the system’s clipboard"
},
{
"name": "fish_clipboard_paste",
"description": "get text from the system’s clipboard"
},
{
"name": "fish_command_not_found",
"description": "what to do when a command wasn’t found"
},
{
"name": "fish_config",
"description": "start the web-based configuration interface"
},
{
"name": "fish_default_key_bindings",
"description": "set emacs key bindings for fish"
},
{
"name": "fish_delta",
"description": "compare functions and completions to the default"
},
{
"name": "fish_git_prompt",
"description": "output git information for use in a prompt"
},
{
"name": "fish_greeting",
"description": "display a welcome message in interactive shells"
},
{
"name": "fish_hg_prompt",
"description": "output Mercurial information for use in a prompt"
},
{
"name": "fish_indent",
"description": "indenter and prettifier"
},
{
"name": "fish_is_root_user",
"description": "check if the current user is root"
},
{
"name": "fish_key_reader",
"description": "explore what characters keyboard keys send"
},
{
"name": "fish_mode_prompt",
"description": "define the appearance of the mode indicator"
},
{
"name": "fish_opt",
"description": "create an option specification for the argparse command"
},
{
"name": "fish_prompt",
"description": "define the appearance of the command line prompt"
},
{
"name": "fish_right_prompt",
"description": "define the appearance of the right-side command line prompt"
},
{
"name": "fish_should_add_to_history",
"description": "decide whether a command should be added to the history"
},
{
"name": "fish_status_to_signal",
"description": "convert exit codes to human-friendly signals"
},
{
"name": "fish_svn_prompt",
"description": "output Subversion information for use in a prompt"
},
{
"name": "fish_tab_title",
"description": "define the terminal tab’s title"
},
{
"name": "fish_title",
"description": "define the terminal’s title"
},
{
"name": "fish_update_completions",
"description": "update completions using manual pages"
},
{
"name": "fish_vcs_prompt",
"description": "output version control system information for use in a prompt"
},
{
"name": "fish_vi_key_bindings",
"description": "set vi key bindings for fish"
},
{
"name": "for",
"description": "perform a set of commands multiple times"
},
{
"name": "funced",
"description": "edit a function interactively"
},
{
"name": "funcsave",
"description": "save the definition of a function to the user’s autoload directory"
},
{
"name": "function",
"description": "create a function"
},
{
"name": "functions",
"description": "print or erase functions"
},
{
"name": "help",
"description": "display fish documentation"
},
{
"name": "history",
"description": "show and manipulate command history"
},
{
"name": "if",
"description": "conditionally execute a command"
},
{
"name": "isatty",
"description": "test if a file descriptor is a terminal"
},
{
"name": "jobs",
"description": "print currently running jobs"
},
{
"name": "math",
"description": "perform mathematics calculations"
},
{
"name": "nextd",
"description": "move forward through directory history"
},
{
"name": "not",
"description": "negate the exit status of a job"
},
{
"name": "open",
"description": "open file in its default application"
},
{
"name": "or",
"description": "conditionally execute a command"
},
{
"name": "path",
"description": "manipulate and check paths"
},
{
"name": "popd",
"description": "move through directory stack"
},
{
"name": "prevd",
"description": "move backward through directory history"
},
{
"name": "printf",
"description": "display text according to a format string"
},
{
"name": "prompt_hostname",
"description": "print the hostname, shortened for use in the prompt"
},
{
"name": "prompt_login",
"description": "describe the login suitable for prompt"
},
{
"name": "prompt_pwd",
"description": "print pwd suitable for prompt"
},
{
"name": "psub",
"description": "perform process substitution"
},
{
"name": "pushd",
"description": "push directory to directory stack"
},
{
"name": "pwd",
"description": "output the current working directory"
},
{
"name": "random",
"description": "generate random number"
},
{
"name": "read",
"description": "read line of input into variables"
},
{
"name": "realpath",
"description": "convert a path to an absolute path without symlinks"
},
{
"name": "return",
"description": "stop the current inner function"
},
{
"name": "set",
"description": "display and change shell variables"
},
{
"name": "set_color",
"description": "set the terminal color"
},
{
"name": "source",
"description": "evaluate contents of file"
},
{
"name": "status",
"description": "query fish runtime information"
},
{
"name": "string",
"description": "manipulate strings"
},
{
"name": "string-collect",
"description": "join strings into one"
},
{
"name": "string-escape",
"description": "escape special characters"
},
{
"name": "string-join",
"description": "join strings with delimiter"
},
{
"name": "string-join0",
"description": "join strings with zero bytes"
},
{
"name": "string-length",
"description": "print string lengths"
},
{
"name": "string-lower",
"description": "convert strings to lowercase"
},
{
"name": "string-match",
"description": "match substrings"
},
{
"name": "string-pad",
"description": "pad strings to a fixed width"
},
{
"name": "string-repeat",
"description": "multiply a string"
},
{
"name": "string-replace",
"description": "replace substrings"
},
{
"name": "string-shorten",
"description": "shorten strings to a width, with an ellipsis"
},
{
"name": "string-split",
"description": "split strings by delimiter"
},
{
"name": "string-split0",
"description": "split on zero bytes"
},
{
"name": "string-sub",
"description": "extract substrings"
},
{
"name": "string-trim",
"description": "remove trailing whitespace"
},
{
"name": "string-unescape",
"description": "expand escape sequences"
},
{
"name": "string-upper",
"description": "convert strings to uppercase"
},
{
"name": "suspend",
"description": "suspend the current shell"
},
{
"name": "switch",
"description": "conditionally execute a block of commands"
},
{
"name": "test",
"description": "perform tests on files and text"
},
{
"name": "time",
"description": "measure how long a command or block takes"
},
{
"name": "trap",
"description": "perform an action when the shell receives a signal"
},
{
"name": "true",
"description": "return a successful result"
},
{
"name": "type",
"description": "locate a command and describe its type"
},
{
"name": "ulimit",
"description": "set or get resource usage limits"
},
{
"name": "umask",
"description": "set or get the file creation mode mask"
},
{
"name": "vared",
"description": "interactively edit the value of an environment variable"
},
{
"name": "wait",
"description": "wait for jobs to complete"
},
{
"name": "while",
"description": "perform a set of commands multiple times"
}
]
================================================
FILE: src/snippets/localeVariables.json
================================================
[
{
"name": "LANG",
"description": "This is the typical environment variable for specifying a locale. A user may set this variable to express the language they speak, their region, and a character encoding. The actual values are specific to their platform, except for special values like C or POSIX. The value of LANG is used for each category unless the variable for that category was set or LC_ALL is set. So typically you only need to set LANG.An example value might be en_US.UTF-8 for the american version of english and the UTF-8 encoding, or de_AT.UTF-8 for the austrian version of german and the UTF-8 encoding. Your operating system might have a locale command that you can call as locale -a to see a list of defined locales. A UTF-8 encoding is recommended."
},
{
"name": "LC_ALL",
"description": "Overrides the LANG environment variable and the values of the other LC_* variables. If this is set, none of the other variables are used for anything. Usually the other variables should be used instead. Use LC_ALL only when you need to override something."
},
{
"name": "LC_COLLATE",
"description": "This determines the rules about equivalence of cases and alphabetical ordering: collation."
},
{
"name": "LC_CTYPE",
"description": "This determines classification rules, like if the type of character is an alpha, digit, and so on. Most importantly, it defines the text encoding - which numbers map to which characters. On modern systems, this should typically be something ending in “UTF-8”."
},
{
"name": "LC_MESSAGES",
"description": "LC_MESSAGES determines the language in which messages are diisplayed."
},
{
"name": "LC_MONETARY",
"description": "Determines currency, how it is formatted, and the symbols used."
},
{
"name": "LC_NUMERIC",
"description": "Sets the locale for formatting numbers."
},
{
"name": "LC_TIME",
"description": "Sets the locale for formatting dates and times."
}
]
================================================
FILE: src/snippets/pipesAndRedirects.json
================================================
[
{
"name": ">",
"description" : "To write standard output to a file, use >DESTINATION"
},
{
"name": ">>",
"description" : "To append standard output to a file, use >>DESTINATION"
},
{
"name": "2>",
"description": "To write standard error to a file, use 2>DESTINATION"
},
{
"name": "2>>",
"description": "To write append standard error to a file, use 2>>DESTINATION"
},
{
"name": "<",
"description": "To write standard input from a file, use ?",
"description": "To not overwrite (“clobber”) an existing file, use >?DESTINATION or 2>?DESTINATION. This is known as the “noclobber” redirection."
},
{
"name": "1>?",
"description": "To not overwrite (“clobber”) an existing file, use >?DESTINATION or 1>?DESTINATION. This is known as the “noclobber” redirection."
},
{
"name": "2>?",
"description": "To not overwrite (“clobber”) an existing file, use >?DESTINATION or 2>?DESTINATION. This is known as the “noclobber” redirection"
},
{
"name": "&-",
"description": "An ampersand followed by a minus sign (&-). The file descriptor will be closed."
},
{
"name": "|",
"description": "Pipe one stream with another. Usually standard output of one command will be piped to standard input of another. OUTPUT | INPUT"
},
{
"name": "&",
"description": "Disown output . OUTPUT &"
},
{
"name": "&>",
"description": "the redirection &> can be used to direct both stdout and stderr to the same destination"
},
{
"name": "2>|",
"description": "pipe a different output file descriptor by prepending its FD number and the output redirect symbol to the pipe"
},
{
"name": "&|",
"description": "the redirection &| can be used to direct both stdout and stderr to the same destination"
},
{
"name": "2>&1",
"description": "Redirect both stderr and stdout"
},
{
"name": "&2",
"description": "An ampersand (&) followed by the number of another file descriptor like &2 for standard error. The output will be written to the destination descriptor."
},
{
"name": ">&2",
"description": "When you say >&2, that will redirect stdout to where stderr is pointing to at that time."
}
]
================================================
FILE: src/snippets/specialFishVariables.json
================================================
[
{
"name": "PATH",
"description": "A list of directories in which to search for commands. This is a common unix variable also used by other tools."
},
{
"name": "CDPATH",
"description": "A list of directories in which the cd builtin looks for a new directory."
},
{
"name": "fish_term24bit",
"description": "If this is set to 1, fish will assume the terminal understands 24-bit RGB color sequences, and won’t translate them to the 256 or 16 color palette. This is often detected automatically."
},
{
"name": "fish_term256",
"description": "If this is set to 1, fish will assume the terminal understands 256 colors, and won’t translate matching colors down to the 16 color palette. This is usually autodetected."
},
{
"name": "fish_ambiguous_width",
"description": "controls the computed width of ambiguous-width characters. This should be set to 1 if your terminal renders these characters as single-width (typical), or 2 if double-width."
},
{
"name": "fish_emoji_width",
"description": "controls whether fish assumes emoji render as 2 cells or 1 cell wide. This is necessary because the correct value changed from 1 to 2 in Unicode 9, and some terminals may not be aware. Set this if you see graphical glitching related to emoji (or other “special” characters). It should usually be auto-detected."
},
{
"name": "fish_autosuggestion_enabled",
"description": "controls if Autosuggestions are enabled. Set it to 0 to disable, anything else to enable. By default they are on."
},
{
"name": "fish_handle_reflow",
"description": "determines whether fish should try to repaint the commandline when the terminal resizes. In terminals that reflow text this should be disabled. Set it to 1 to enable, anything else to disable."
},
{
"name": "fish_key_bindings",
"description": "the name of the function that sets up the keyboard shortcuts for the command-line editor."
},
{
"name": "fish_escape_delay_ms",
"description": "sets how long fish waits for another key after seeing an escape, to distinguish pressing the escape key from the start of an escape sequence. The default is 30ms. Increasing it increases the latency but allows pressing escape instead of alt for alt+character bindings. For more information, see the chapter in the bind documentation."
},
{
"name": "fish_sequence_key_delay_ms",
"description": "sets how long fish waits for another key after seeing a key that is part of a longer sequence, to disambiguate. For instance if you had bound \\cx\\ce to open an editor, fish would wait for this long in milliseconds to see a ctrl-e after a ctrl-x. If the time elapses, it will handle it as a ctrl-x (by default this would copy the current commandline to the clipboard). See also Key sequences."
},
{
"name": "fish_complete_path",
"description": "determines where fish looks for completion. When trying to complete for a command, fish looks for files in the directories in this variable."
},
{
"name": "fish_cursor_selection_mode",
"description": "controls whether the selection is inclusive or exclusive of the character under the cursor (see Copy and Paste)."
},
{
"name": "fish_function_path",
"description": "determines where fish looks for functions. When fish autoloads a function, it will look for files in these directories."
},
{
"name": "fish_greeting",
"description": "the greeting message printed on startup. This is printed by a function of the same name that can be overridden for more complicated changes (see funced)"
},
{
"name": "fish_history",
"description": "the current history session name. If set, all subsequent commands within an interactive fish session will be logged to a separate file identified by the value of the variable. If unset, the default session name “fish” is used. If set to an empty string, history is not saved to disk (but is still available within the interactive session)."
},
{
"name": "fish_trace",
"description": "if set and not empty, will cause fish to print commands before they execute, similar to set -x in bash. The trace is printed to the path given by the --debug-output option to fish or the FISH_DEBUG_OUTPUT variable. It goes to stderr by default."
},
{
"name": "FISH_DEBUG",
"description": "Controls which debug categories fish enables for output, analogous to the --debug option."
},
{
"name": "FISH_DEBUG_OUTPUT",
"description": "Specifies a file to direct debug output to."
},
{
"name": "fish_user_paths",
"description": "a list of directories that are prepended to PATH. This can be a universal variable."
},
{
"name": "umask",
"description": "the current file creation mask. The preferred way to change the umask variable is through the umask function. An attempt to set umask to an invalid value will always fail."
},
{
"name": "BROWSER",
"description": "your preferred web browser. If this variable is set, fish will use the specified browser instead of the system default browser to display the fish documentation."
}
]
================================================
FILE: src/snippets/statusNumbers.json
================================================
[
{
"name": "0",
"description": "0 is generally the exit status of commands if they successfully performed the requested operation."
},
{
"name": "1",
"description": "1 is generally the exit status of commands if they failed to perform the requested operation."
},
{
"name": "121",
"description": "121 is generally the exit status of commands if they were supplied with invalid arguments."
},
{
"name": "123",
"description": "123 means that the command was not executed because the command name contained invalid characters."
},
{
"name": "124",
"description": "124 means that the command was not executed because none of the wildcards in the command produced any matches."
},
{
"name": "125",
"description": "125 means that while an executable with the specified name was located, the operating system could not actually execute the command."
},
{
"name": "126",
"description": "126 means that while a file with the specified name was located, it was not executable."
},
{
"name": "127",
"description": "127 means that no function, builtin or command with the given name could be located."
},
{
"name": "128",
"description": "128 is used when a process exits a signal, plus the number of the signal"
}
]
================================================
FILE: src/snippets/syntaxHighlightingVariables.json
================================================
[
{
"name": "fish_color_normal",
"description": "default color"
},
{
"name": "fish_color_command",
"description": "commands like echo"
},
{
"name": "fish_color_keyword",
"description": "keywords like if - this falls back on the command color if unset"
},
{
"name": "fish_color_quote",
"description": "quoted text like \"abc\""
},
{
"name": "fish_color_redirection",
"description": "IO redirections like >/dev/null"
},
{
"name": "fish_color_end",
"description": "process separators like ; and &"
},
{
"name": "fish_color_error",
"description": "syntax errors"
},
{
"name": "fish_color_param",
"description": "ordinary command parameters"
},
{
"name": "fish_color_valid_path",
"description": "parameters that are filenames (if the file exists)"
},
{
"name": "fish_color_option",
"description": "options starting with “-”, up to the first “--” parameter"
},
{
"name": "fish_color_comment",
"description": "comments like ‘# important’"
},
{
"name": "fish_color_selection",
"description": "selected text in vi visual mode"
},
{
"name": "fish_color_operator",
"description": "parameter expansion operators like * and ~"
},
{
"name": "fish_color_escape",
"description": "character escapes like \\n and \\x70"
},
{
"name": "fish_color_autosuggestion",
"description": "autosuggestions (the proposed rest of a command)"
},
{
"name": "fish_color_cwd",
"description": "the current working directory in the default prompt"
},
{
"name": "fish_color_cwd_root",
"description": "the current working directory in the default prompt for the root user"
},
{
"name": "fish_color_user",
"description": "the username in the default prompt"
},
{
"name": "fish_color_host",
"description": "the hostname in the default prompt"
},
{
"name": "fish_color_host_remote",
"description": "the hostname in the default prompt for remote sessions (like ssh)"
},
{
"name": "fish_color_status",
"description": "the last command’s nonzero exit code in the default prompt"
},
{
"name": "fish_color_cancel",
"description": "the ‘^C’ indicator on a canceled command"
},
{
"name": "fish_color_search_match",
"description": "history search matches and selected pager items (background only)"
},
{
"name": "fish_color_history_current",
"description": "the current position in the history for commands like dirh and cdh"
},
{
"name": "fish_pager_color_progress",
"description": "the progress bar at the bottom left corner"
},
{
"name": "fish_pager_color_background",
"description": "the background color of a line"
},
{
"name": "fish_pager_color_prefix",
"description": "the prefix string, i.e. the string that is to be completed"
},
{
"name": "fish_pager_color_completion",
"description": "the completion itself, i.e. the proposed rest of the string"
},
{
"name": "fish_pager_color_description",
"description": "the completion description"
},
{
"name": "fish_pager_color_selected_background",
"description": "background of the selected completion"
},
{
"name": "fish_pager_color_selected_prefix",
"description": "prefix of the selected completion"
},
{
"name": "fish_pager_color_selected_completion",
"description": "suffix of the selected completion"
},
{
"name": "fish_pager_color_selected_description",
"description": "description of the selected completion"
},
{
"name": "fish_pager_color_secondary_background",
"description": "background of every second unselected completion"
},
{
"name": "fish_pager_color_secondary_prefix",
"description": "prefix of every second unselected completion"
},
{
"name": "fish_pager_color_secondary_completion",
"description": "suffix of every second unselected completion"
},
{
"name": "fish_pager_color_secondary_description",
"description": "description of every second unselected completion"
}
]
================================================
FILE: src/utils/builtins.ts
================================================
import { spawnSync, SpawnSyncOptionsWithStringEncoding } from 'child_process';
export const BuiltInList = [
'!',
'.',
':',
'[',
'_',
'abbr',
'and',
'argparse',
'begin',
'bg',
'bind',
'block',
'break',
'breakpoint',
'builtin',
'case',
'cd',
'command',
'commandline',
'complete',
'contains',
'continue',
'count',
'disown',
'echo',
'else',
'emit',
'end',
'eval',
'exec',
'exit',
'false',
'fg',
'fish_indent',
'fish_key_reader',
'for',
'function',
'functions',
'history',
'if',
'jobs',
'math',
'not',
'or',
'path',
'printf',
'pwd',
'random',
'read',
'realpath',
'return',
'set',
'set_color',
'source',
'status',
'string',
'switch',
'test',
'time',
'true',
'type',
'ulimit',
'wait',
'while',
];
/**
* You can generate this list by running `builtin --names` in a fish session
* note that '.', and ':' are removed from the list because they do not contain
* a man-page
*/
const BuiltInSET = new Set(BuiltInList);
/**
* check if string is one of the default fish builtin functions
*/
export function isBuiltin(word: string): boolean {
return BuiltInSET.has(word);
}
const reservedKeywords = [
'[',
'_',
'and',
'argparse',
'begin',
'break',
'builtin',
'case',
'command',
'continue',
'else',
'end',
'eval',
'exec',
'for',
'function',
'if',
'not',
'or',
'read',
'return',
'set',
'status',
'string',
'switch',
'test',
'time',
'and',
'while',
];
const ReservedKeywordSet = new Set(reservedKeywords);
/**
* Reserved keywords are not allowed as function names.
* Found on the `function` manpage.
*/
export function isReservedKeyword(word: string): boolean {
return ReservedKeywordSet.has(word);
}
/**
* Find the fish shell path using `which fish`
*/
export function findShell() {
const result = spawnSync('which fish', { shell: true, stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf-8' });
return result.stdout?.toString().trim() || 'fish';
}
const fishShell = findShell();
const spawnOpts: SpawnSyncOptionsWithStringEncoding = {
shell: fishShell,
stdio: ['ignore', 'pipe', 'inherit'],
encoding: 'utf-8',
};
/**
* Helper function to safely execute fish commands and return output as lines.
* Returns an empty array if stdout is not available or command fails.
*/
function execFishCommand(command: string): string[] {
const result = spawnSync(command, spawnOpts);
return result.stdout?.toString().split('\n') || [];
}
function createFunctionNamesList() {
return execFishCommand('functions --names | string split -n \'\\n\'');
}
export const FunctionNamesList = createFunctionNamesList();
export function isFunction(word: string): boolean {
return FunctionNamesList.includes(word);
}
function createFunctionEventsList() {
return execFishCommand('functions --handlers | string match -vr \'^Event \\w+\' | string split -n \'\\n\'');
}
/**
* Consider using these utilities to check if a word is a event on a function/emit/trap
*/
export const EventNamesList = createFunctionEventsList();
export function isEvent(word: string): boolean {
return EventNamesList.includes(word);
}
function createAbbrList() {
return execFishCommand('abbr --show');
}
export const AbbrList = createAbbrList();
function createGlobalVariableList() {
return execFishCommand('set -n');
}
export const GlobalVariableList = createGlobalVariableList();
/**
* TO get the list of commands with potential subcommands, you can use:
*
* >_ cd /usr/share/fish/completions/
* >_ for i in (rg -e '-a' -l); echo (string split -f 1 '.fish' -m1 $i);end
*
* example commands with potential subcommands
* • string split ...
* • killall node
* • man vim
* • command fish
*
* useful when checking the current Command for documentation/completion
* suggestions. If a match is hit, check one more node back, and if it is
* not a command, stop searching backwards.
*/
// List of global aliases removed (check history if needed in future)
================================================
FILE: src/utils/cli-dump-tree.ts
================================================
import { LspDocument } from '../document';
import { Analyzer, analyzer } from '../analyze';
import { logger } from '../logger';
import { SyncFileHelper } from './file-operations';
import path from 'path';
import chalk from 'chalk';
import { CommanderSubcommand } from './commander-cli-subcommands';
import { semanticTokenHandler } from '../semantic-tokens';
import { FishSemanticTokens } from './semantics';
import { type FishSymbol, processNestedTree, formatFishSymbolTree } from '../parsing/symbol';
import { createInterface } from 'node:readline';
import { startServer } from './startup';
import * as os from 'os';
/**
* Checks whether a CLI dump flag value indicates stdin input.
* Returns true when the flag is unset, boolean `true`, empty string, or `"-"`.
*/
function isDumpFlagStdin(value: string | boolean | undefined): boolean {
if (!value || value === true) return true;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed === '' || trimmed === '-';
}
return false;
}
interface ParseTreeOutput {
source: string;
parseTree: string;
}
interface SemanticTokensOutput {
source: string;
tokens: string;
}
/**
* Reads all content from stdin, line by line.
* It works for both piped input and manual terminal input.
* @returns Promise - The content from stdin, or an empty string if there is no input.
*/
async function readFromStdin(): Promise {
const rl = createInterface({
input: process.stdin,
terminal: false, // Set to false to avoid issues with piped input
});
let data = '';
for await (const line of rl) {
data += line + '\n';
}
return data.trim(); // Trim trailing newline for cleaner output
}
/**
* Debug utility that acts like tree-sitter-cli on a source file.
* Shows both the raw source code and the tree-sitter parse tree.
*
* @param document - The LspDocument to debug
* @returns Object containing both source and parse tree as strings
*/
export function debugWorkspaceDocument(document: LspDocument, useColors: boolean = true): ParseTreeOutput {
const source = document.getText();
// Parse the document using the existing analyzer's parser
const tree = analyzer.parser.parse(source);
// Convert the parse tree to a readable string format
const parseTree = formatSyntaxTree(tree.rootNode, 0, useColors);
return {
source,
parseTree,
};
}
/**
* Color scheme for different node types
*/
const nodeTypeColors = {
// Fish-specific node types
command: chalk.blue,
command_name: chalk.blue.bold,
argument: chalk.green,
option: chalk.yellow,
redirection: chalk.magenta,
pipe: chalk.cyan,
variable_expansion: chalk.red,
variable_name: chalk.red.bold,
string: chalk.green,
quoted_string: chalk.green,
double_quote_string: chalk.green,
single_quote_string: chalk.green,
concatenation: chalk.yellow,
word: chalk.yellow,
comment: chalk.yellow.dim,
function_definition: chalk.blue.bold,
if_statement: chalk.cyan.bold,
for_statement: chalk.cyan.bold,
while_statement: chalk.cyan.bold,
switch_statement: chalk.cyan.bold,
case_clause: chalk.cyan,
begin_statement: chalk.magenta.bold,
end: chalk.magenta.bold,
program: chalk.white.bold,
integer: chalk.yellow,
float: chalk.yellow,
boolean: chalk.yellow,
identifier: chalk.white.bgBlack,
ERROR: chalk.red.bold,
// Symbols and operators
'(': chalk.white.bold.italic,
')': chalk.white.bold.italic,
'[': chalk.white.bold.italic,
']': chalk.white.bold.italic,
'{': chalk.white.bold.italic,
'}': chalk.white.bold.italic,
'|': chalk.cyan.bold,
'&&': chalk.cyan,
'||': chalk.cyan,
';': chalk.white.bold.italic,
'\n': chalk.white.bold.italic,
// Default fallback
default: chalk.white.bgBlack,
};
/**
* Color scheme for parentheses based on nesting depth
*/
const parenthesesColors = [
chalk.white,
chalk.yellow,
chalk.cyan,
chalk.magenta,
chalk.green,
chalk.blue,
chalk.red,
];
/**
* Get color function for a given node type
*/
function getNodeTypeColor(nodeType: string): (text: string) => string {
return nodeTypeColors[nodeType as keyof typeof nodeTypeColors] || nodeTypeColors.default;
}
/**
* Get color function for parentheses based on depth
*/
function getParenthesesColor(depth: number): (text: string) => string {
return parenthesesColors[depth % parenthesesColors.length] || chalk.yellowBright;
}
/**
* Recursively formats a syntax tree node into a readable string representation
* similar to tree-sitter-cli output, with comprehensive color highlighting.
*/
function formatSyntaxTree(node: any, depth: number = 0, useColors: boolean = true): string {
const indent = ' '.repeat(depth);
const rawNodeType = node.type || 'unknown';
// If node type is just whitespace, escape it for visibility
const nodeType = rawNodeType.trim() === '' ? escapeWhitespace(rawNodeType) : rawNodeType;
const startPos = `${node.startPosition?.row || 0}:${node.startPosition?.column || 0}`;
const endPos = `${node.endPosition?.row || 0}:${node.endPosition?.column || 0}`;
// Get colors for this depth and node type (or no-op functions if colors disabled)
const parenColor = useColors ? getParenthesesColor(depth) : (text: string) => text;
const typeColor = useColors ? getNodeTypeColor(nodeType) : (text: string) => text;
const rangeColor = useColors ? chalk.dim.white.dim : (text: string) => text;
let result = `${indent}${parenColor('(')}${typeColor(nodeType)} ${rangeColor(`[${startPos}, ${endPos}]`)}`;
// If it's a leaf node with text, show the text with proper escaping
if (node.children.length === 0 && node.text) {
const escapedText = escapeWhitespace(node.text);
if (useColors) {
result += ` ${chalk.dim('"')}${chalk.italic.green(escapedText)}${chalk.dim('"')}`;
} else {
result += ` "${escapedText}"`;
}
}
// Handle children
if (node.children.length > 0) {
result += '\n';
// Recursively format children
for (const child of node.children) {
result += formatSyntaxTree(child, depth + 1, useColors);
}
result += `${indent}${parenColor(')')}`;
} else {
result += parenColor(')');
}
// Always end with a newline, regardless of whether this node has children
result += '\n';
return result;
}
/**
* Escapes whitespace characters in text for readable display
* Uses JSON.stringify for proper escaping similar to tree-sitter-cli
*/
function escapeWhitespace(text: string): string {
// Use JSON.stringify to properly escape the string, then remove the outer quotes
const escaped = JSON.stringify(text);
return escaped.slice(1, -1); // Remove the surrounding quotes
}
/**
* Pretty prints the debug output to console in a readable format.
*
* @param document - The LspDocument to debug
*/
export function logTreeSitterDocumentDebug(document: LspDocument): void {
const { source, parseTree } = debugWorkspaceDocument(document);
logger.log('='.repeat(80));
logger.log(`DEBUG: ${document.getFileName()}`);
logger.log('='.repeat(80));
logger.log('SOURCE:');
logger.log('-'.repeat(40));
// Print source with line numbers
const lines = source.split('\n');
lines.forEach((line, index) => {
logger.log(`${(index + 1).toString().padStart(3)}: ${line}`);
});
logger.log('\n' + '-'.repeat(40));
logger.log('PARSE TREE:');
logger.log('-'.repeat(40));
logger.log(parseTree);
logger.log('='.repeat(80));
}
export function returnParseTreeString(document: LspDocument, useColors: boolean = true): string {
const { parseTree } = debugWorkspaceDocument(document, useColors);
return parseTree;
}
export function expandParseCliTreeFile(input: string | undefined): string {
if (!input || !input.trim()) {
return '';
}
const resultPath = SyncFileHelper.expandEnvVars(input);
if (SyncFileHelper.isAbsolutePath(resultPath)) {
return resultPath;
}
return path.resolve(resultPath);
}
export async function cliDumpParseTree(document: LspDocument, useColors: boolean = true): Promise<0 | 1> {
await Analyzer.initialize();
const { parseTree } = debugWorkspaceDocument(document, useColors);
// Output the parse tree to stdout
logger.logToStdout(parseTree);
if (parseTree.trim().length === 0) {
const errorMsg = useColors ? chalk.red('No parse tree available for this document.') : 'No parse tree available for this document.';
logger.logToStderr(errorMsg);
return 1;
}
return 0;
}
// Entire wrapper for `src/cli.ts` usage of this function
export async function handleCLiDumpParseTree(args: CommanderSubcommand.info.schemaType): Promise<0 | 1> {
const useColors = !args.noColor; // Use colors unless --no-color flag is set
const isStdin = isDumpFlagStdin(args.dumpParseTree);
// Read stdin BEFORE startServer(), since startServer() hijacks stdin for the LSP connection
let stdinContent = '';
if (isStdin) {
stdinContent = await readFromStdin();
if (stdinContent.trim() === '') {
logger.logToStderr('Error: No input provided. Please provide either a file path or pipe content to stdin.');
return 1;
}
}
startServer();
await Analyzer.initialize();
if (isStdin) {
const doc = LspDocument.createTextDocumentItem('stdin.fish', stdinContent);
return await cliDumpParseTree(doc, useColors);
}
// Original file-based logic
const filePath = expandParseCliTreeFile(args.dumpParseTree as string);
if (!SyncFileHelper.isFile(filePath)) {
logger.logToStderr(`Error: Cannot read file at ${filePath}. Please check the file path and permissions.`);
process.exit(1);
}
const doc = LspDocument.createFromPath(filePath);
return await cliDumpParseTree(doc, useColors);
}
// ============================================================================
// Semantic Tokens Dumping Functions
// ============================================================================
/**
* Color scheme for semantic token types
*/
const tokenTypeColors = {
function: chalk.blue.bold,
variable: chalk.red,
keyword: chalk.magenta.bold,
decorator: chalk.yellow,
string: chalk.green,
operator: chalk.cyan,
comment: chalk.gray,
default: chalk.white,
};
/**
* Get color function for a given token type
*/
function getTokenTypeColor(tokenType: string, useColors: boolean): (text: string) => string {
if (!useColors) return (text: string) => text;
return tokenTypeColors[tokenType as keyof typeof tokenTypeColors] || tokenTypeColors.default;
}
/**
* Decode modifiers from bitmask
*/
function decodeModifiers(modifiersMask: number): string[] {
const modifiers: string[] = [];
const legend = FishSemanticTokens.legend.tokenModifiers;
for (let i = 0; i < legend.length; i++) {
if (modifiersMask & 1 << i) {
modifiers.push(legend[i]!);
}
}
return modifiers;
}
/**
* Formats semantic tokens into a human-readable string representation.
* Shows each token with its position, length, type, and modifiers.
*/
function formatSemanticTokens(data: number[], source: string, useColors: boolean): string {
if (data.length === 0) {
return useColors ? chalk.gray('(no semantic tokens)') : '(no semantic tokens)';
}
const lines = source.split('\n');
const legend = FishSemanticTokens.legend;
const results: string[] = [];
// Semantic tokens are encoded as a flat array of integers
// [deltaLine, deltaStart, length, tokenType, modifiers, ...]
let currentLine = 0;
let currentChar = 0;
for (let i = 0; i < data.length; i += 5) {
const deltaLine = data[i]!;
const deltaStart = data[i + 1]!;
const length = data[i + 2]!;
const tokenTypeIndex = data[i + 3]!;
const modifiersMask = data[i + 4]!;
// Update position
currentLine += deltaLine;
if (deltaLine > 0) {
currentChar = deltaStart;
} else {
currentChar += deltaStart;
}
// Get token information
const tokenType = legend.tokenTypes[tokenTypeIndex] || 'unknown';
const modifiers = decodeModifiers(modifiersMask);
// Extract the actual text from the source
const line = lines[currentLine] || '';
const tokenText = line.substring(currentChar, currentChar + length);
// Format the output
const posStr = `${currentLine}:${currentChar}`;
const typeColor = getTokenTypeColor(tokenType, useColors);
const dimColor = useColors ? chalk.dim : (text: string) => text;
const boldColor = useColors ? chalk.bold : (text: string) => text;
let tokenInfo = `${dimColor(posStr.padEnd(10))} `;
tokenInfo += `${typeColor(tokenType.padEnd(12))} `;
tokenInfo += `${dimColor('len=')}${length.toString().padEnd(3)} `;
if (modifiers.length > 0) {
const modStr = `[${modifiers.join(', ')}]`;
tokenInfo += `${dimColor(modStr.padEnd(30))} `;
} else {
tokenInfo += `${dimColor(''.padEnd(30))} `;
}
tokenInfo += `${boldColor('"')}${tokenText}${boldColor('"')}`;
results.push(tokenInfo);
}
return results.join('\n');
}
/**
* Debug utility that shows semantic tokens for a source file.
* Displays the source code and the semantic tokens.
*
* @param document - The LspDocument to debug
* @param useColors - Whether to use color output
* @returns Object containing both source and semantic tokens as strings
*/
export function debugSemanticTokens(document: LspDocument, useColors: boolean = true): SemanticTokensOutput {
const source = document.getText();
// Get semantic tokens for the document using the simplified handler
const semanticTokens = semanticTokenHandler({
textDocument: { uri: document.uri },
});
// Format the semantic tokens into a readable string
const tokens = formatSemanticTokens(semanticTokens.data, source, useColors);
return {
source,
tokens,
};
}
/**
* CLI handler for dumping semantic tokens
*/
export async function cliDumpSemanticTokens(document: LspDocument, useColors: boolean = true): Promise<0 | 1> {
await Analyzer.initialize();
// Analyze the document to ensure the analyzer cache is populated
analyzer.analyze(document);
const { tokens } = debugSemanticTokens(document, useColors);
// Output the semantic tokens to stdout
logger.logToStdout(tokens);
if (tokens.trim().length === 0 || tokens.includes('(no semantic tokens)')) {
const errorMsg = useColors ? chalk.red('No semantic tokens available for this document.') : 'No semantic tokens available for this document.';
logger.logToStderr(errorMsg);
return 1;
}
return 0;
}
// ============================================================================
// Symbol Tree Dumping Functions
// ============================================================================
/**
* Color scheme for FishSymbolKind values
*/
const symbolKindColors: Record string> = {
FUNCTION: chalk.blue.bold,
ALIAS: chalk.blue,
SET: chalk.red,
EXPORT: chalk.red.bold,
READ: chalk.green,
FOR: chalk.cyan,
VARIABLE: chalk.red,
FUNCTION_VARIABLE: chalk.magenta,
ARGPARSE: chalk.yellow,
COMPLETE: chalk.cyan.bold,
EVENT: chalk.magenta.bold,
FUNCTION_EVENT: chalk.magenta,
INLINE_VARIABLE: chalk.red,
};
/**
* Short tag prefix for each symbol kind category
*/
const symbolIconTag: Record = {
FUNCTION: '',
ALIAS: '',
COMPLETE: '',
SET: '',
READ: '',
FOR: '',
VARIABLE: '',
FUNCTION_VARIABLE: '',
EXPORT: '',
INLINE_VARIABLE: '',
ARGPARSE: '',
EVENT: '',
FUNCTION_EVENT: '',
};
const symbolTextTag: Record = {
FUNCTION: 'f',
ALIAS: 'f',
COMPLETE: 'f',
SET: 'v',
READ: 'v',
FOR: 'v',
VARIABLE: 'v',
FUNCTION_VARIABLE: 'v',
EXPORT: 'v',
INLINE_VARIABLE: 'v',
ARGPARSE: 'v',
EVENT: 'e',
FUNCTION_EVENT: 'e',
};
const treeColor = chalk.gray.bold;
const tagColors: Record string> = {
f: chalk.blue,
v: chalk.magenta,
e: chalk.yellow,
};
function formatSymbolLine(symbol: FishSymbol, useIcons: boolean): string {
const scopeTag = symbol.scope?.scopeTag || 'unknown';
const kindColor = symbolKindColors[symbol.fishKind] || chalk.white;
const textTag = symbolTextTag[symbol.fishKind] || '?';
const tag = useIcons ? symbolIconTag[symbol.fishKind] || textTag : textTag;
const tagColor = tagColors[textTag] || chalk.white;
const tagStr = tagColor(tag);
const nameStr = chalk.bold(symbol.name);
const { start } = symbol.toLocation().range;
const posStr = chalk.dim(`[${start.line}, ${start.character}]`);
const scopeStr = chalk.dim(`(${scopeTag})`);
const kindStr = kindColor(`(${symbol.fishKind})`);
return `${tagStr} ${nameStr} ${posStr} ${scopeStr} ${kindStr}`;
}
function formatColoredSymbolNodes(symbols: FishSymbol[], prefix: string, useIcons: boolean): string {
let result = '';
for (let i = 0; i < symbols.length; i++) {
const symbol = symbols[i]!;
const isLast = i === symbols.length - 1;
const connector = treeColor(isLast ? '└── ' : '├── ');
const childPrefix = treeColor(isLast ? ' ' : '│ ');
result += `${prefix}${connector}${formatSymbolLine(symbol, useIcons)}\n`;
if (symbol.children && symbol.children.length > 0) {
result += formatColoredSymbolNodes(symbol.children, prefix + childPrefix, useIcons);
}
}
return result;
}
/**
* Formats the symbol tree with color highlighting and tree-style connectors
*/
function formatColoredSymbolTree(symbols: FishSymbol[], rootLabel: string, useIcons: boolean): string {
return chalk.black.dim(rootLabel) + '\n' + formatColoredSymbolNodes(symbols, '', useIcons);
}
/**
* CLI handler for dumping the symbol tree
*/
export async function cliDumpSymbolTree(document: LspDocument, useColors: boolean = true, useIcons: boolean = true): Promise<0 | 1> {
await Analyzer.initialize();
// Parse and analyze the document
const tree = analyzer.parser.parse(document.getText());
const rootNode = tree.rootNode;
// Build the FishSymbol tree
const symbols = processNestedTree(document, ...rootNode.children);
// Determine root label from document URI
// const uriPath = document.uri.replace(/^file:\/\//, '');
let rootLabel = document.uri.includes('stdin')
? `/proc/${process.pid}/fd/0`
: document.getFilePath();
rootLabel = rootLabel.replace(os.homedir(), '~');
// Format the symbol tree
const output = useColors
? formatColoredSymbolTree(symbols, rootLabel, useIcons)
: rootLabel + '\n' + formatFishSymbolTree(symbols);
if (output.trim().length === 0) {
const errorMsg = useColors ? chalk.red('No symbols found in this document.') : 'No symbols found in this document.';
logger.logToStderr(errorMsg);
return 1;
}
logger.logToStdout(output);
return 0;
}
/**
* Main wrapper for `src/cli.ts` usage of symbol tree dumping
*/
export async function handleCLiDumpSymbolTree(args: CommanderSubcommand.info.schemaType): Promise<0 | 1> {
const useColors = !args.noColor;
const useIcons = args.icons !== false;
const isStdin = isDumpFlagStdin(args.dumpSymbolTree);
// Read stdin BEFORE startServer(), since startServer() hijacks stdin for the LSP connection
let stdinContent = '';
if (isStdin) {
stdinContent = await readFromStdin();
if (stdinContent.trim() === '') {
logger.logToStderr('Error: No input provided. Please provide either a file path or pipe content to stdin.');
return 1;
}
}
startServer();
await Analyzer.initialize();
if (isStdin) {
const doc = LspDocument.createTextDocumentItem('stdin.fish', stdinContent);
return await cliDumpSymbolTree(doc, useColors, useIcons);
}
// File-based logic
const filePath = expandParseCliTreeFile(args.dumpSymbolTree as string);
if (!SyncFileHelper.isFile(filePath)) {
logger.logToStderr(`Error: Cannot read file at ${filePath}. Please check the file path and permissions.`);
process.exit(1);
}
const doc = LspDocument.createFromPath(filePath);
return await cliDumpSymbolTree(doc, useColors, useIcons);
}
/**
* Main wrapper for `src/cli.ts` usage of semantic tokens dumping
*/
export async function handleCLiDumpSemanticTokens(args: CommanderSubcommand.info.schemaType): Promise<0 | 1> {
const useColors = !args.noColor; // Use colors unless --no-color flag is set
const isStdin = isDumpFlagStdin(args.dumpSemanticTokens);
// Read stdin BEFORE startServer(), since startServer() hijacks stdin for the LSP connection
let stdinContent = '';
if (isStdin) {
stdinContent = await readFromStdin();
if (stdinContent.trim() === '') {
logger.logToStderr('Error: No input provided. Please provide either a file path or pipe content to stdin.');
return 1;
}
}
startServer();
if (isStdin) {
const doc = LspDocument.createTextDocumentItem('stdin.fish', stdinContent);
return await cliDumpSemanticTokens(doc, useColors);
}
// Original file-based logic
const filePath = expandParseCliTreeFile(args.dumpSemanticTokens as string);
if (!SyncFileHelper.isFile(filePath)) {
logger.logToStderr(`Error: Cannot read file at ${filePath}. Please check the file path and permissions.`);
process.exit(1);
}
const doc = LspDocument.createFromPath(filePath);
return await cliDumpSemanticTokens(doc, useColors);
}
================================================
FILE: src/utils/commander-cli-subcommands.ts
================================================
import chalk from 'chalk';
import fs, { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import path, { resolve } from 'path';
import { z } from 'zod';
import PackageJSON from '../../package.json';
import { commandBin } from '../cli';
import { config } from '../config';
import { logger } from '../logger';
import { SyncFileHelper } from './file-operations';
import { getCurrentExecutablePath, getFishBuildTimeFilePath, getManFilePath, getProjectRootPath, isBundledEnvironment } from './path-resolution';
import { maxWidthForOutput } from './startup';
import { vfs } from '../virtual-fs';
import FishServer from '../server';
/**
* Accumulate the arguments into two arrays, '--enable' and '--disable'
* More than one enable/disable flag can be used, but the output will be
* the stored across two resulting arrays (if both flags have values as input).
* Handles some of the default commands, such as '--help', and '-s, --show'
* from the command line args.
*/
export function accumulateStartupOptions(args: string[]): {
enabled: string[];
disabled: string[];
dumpCmd: boolean;
} {
const [_subcmd, ...options] = args;
const filteredOptions = filterStartCommandArgs(options);
const [enabled, disabled]: [string[], string[]] = [[], []];
let dumpCmd = false;
let current: string[];
filteredOptions?.forEach(arg => {
if (['--enable', '--disable'].includes(arg)) {
if (arg === '--enable') {
current = enabled;
}
if (arg === '--disable') {
current = disabled;
}
return;
}
if (['-h', '--help', 'help'].includes(arg)) {
// commandBin.commands.find(command => command.name() === subcmd)!.outputHelp();
// process.exit(0);
return;
}
if (['--dump'].includes(arg)) {
logger.logToStdout('SEEN SHOW COMMAND! dumping...');
dumpCmd = true;
return;
}
if (arg.startsWith('-')) {
return;
}
if (current) {
current?.push(arg);
}
});
return { enabled, disabled, dumpCmd };
}
export namespace SubcommandEnv {
export type ArgsType = {
create?: boolean;
show?: boolean;
showDefault?: boolean;
only?: string[] | string | undefined;
comments?: boolean;
global?: boolean;
local?: boolean;
export?: boolean;
confd?: boolean;
names?: boolean;
joined?: boolean;
json?: boolean;
};
export type HandlerOptionsType = {
only: string[] | undefined;
comments: boolean;
global: boolean;
local: boolean;
export: boolean;
confd: boolean;
json: boolean;
};
export const defaultHandlerOptions: HandlerOptionsType = {
only: undefined,
comments: true,
global: true,
local: false,
export: true,
confd: false,
json: false,
};
/**
* Get the output type based on the cli env args
* Only one of these options is allowed at a time:
* -c, --create `create the default env file`
* --show-default: `same as --create`
* -s, --show: `show the current values in use`
* If `fish-lsp env` is called without any of the flags above, it will default to `create`
*/
export function getOutputType(args: ArgsType): 'show' | 'create' | 'showDefault' {
return args.showDefault ? 'showDefault' : args.show ? 'show' : 'create';
}
export function getOnly(args: ArgsType): string[] | undefined {
if (args.only) {
const only = Array.isArray(args.only) ? args.only : [args.only];
return only.reduce((acc: string[], value) => {
acc.push(...value.split(',').map(v => v.trim()));
return acc;
}, []);
}
return undefined;
}
export function toEnvOutputOptions(args: ArgsType): HandlerOptionsType {
const only = getOnly(args);
return {
only,
comments: args.comments ?? true,
global: args.global ?? true,
local: args.local ?? false,
export: args.export ?? true,
confd: args.confd ?? false,
json: args.json ?? false,
};
}
}
export function getEnvOnlyArgs(cliEnvOnly: string | string[] | undefined): string[] | undefined {
const splitOnlyValues = (v: string) => v.split(',').map(value => value.trim());
const isValidOnlyInput = (v: unknown): v is string | string[] =>
typeof v === 'string'
|| Array.isArray(v) && v.every((value) => typeof value === 'string');
const onlyArrayBuilder = (v: string | string[]) => {
if (typeof v === 'string') {
return splitOnlyValues(v);
}
return v.reduce((acc: string[], value) => {
acc.push(...splitOnlyValues(value));
return acc;
}, []);
};
if (!cliEnvOnly || !isValidOnlyInput(cliEnvOnly)) return undefined;
const only = Array.from(cliEnvOnly);
return onlyArrayBuilder(only);
}
// filter out the start command args that are not used for the --enable/--disable values
function filterStartCommandArgs(args: string[]): string[] {
const filteredArgs = [];
let skipNext = false;
for (const arg of args) {
// Skip this argument if the previous iteration marked it for skipping
if (skipNext) {
skipNext = false;
continue;
}
// Check if the current arg is one of the flags that take values
if (arg === '--socket' || arg === '--max-files' || arg === '--memory-limit') {
skipNext = true; // Skip both the flag and its value
continue;
}
// Check if the current arg is one of the flags without values
if (arg === '--stdio' || arg === '--node-ipc') {
continue;
}
// For flags with values in the format --flag=value
if (arg.startsWith('--socket=') || arg.startsWith('--max-files=') || arg.startsWith('--memory-limit=')) {
continue;
}
// Otherwise, keep the argument
filteredArgs.push(arg);
}
return filteredArgs;
}
/// HELPERS
export const smallFishLogo = () => '><(((°> FISH LSP';
export const RepoUrl = PackageJSON.repository?.url.slice(0, -4);
export const PackageVersion = PackageJSON.version;
export const PathObj: { [K in 'bin' | 'root' | 'path' | 'manFile' | 'execFile']: string } = {
['bin']: getCurrentExecutablePath(),
['root']: getProjectRootPath(),
['path']: getProjectRootPath(),
['execFile']: getCurrentExecutablePath(),
['manFile']: getManFilePath(),
};
export type VersionTuple = {
major: number;
minor: number;
patch: number;
raw: string;
};
export namespace DepVersion {
/**
* Extracts the major, minor, and patch version numbers from a version string.
*/
export function minimumNodeVersion(): VersionTuple {
const versionString = PackageJSON.engines.node?.toString();
const version = extract(versionString);
if (!version) {
return extract('>=20.0.0')!; // Fallback to a default version if extraction fails
}
return version;
}
export function extract(versionString: string): VersionTuple | null {
// Match major.minor.patch, ignoring operators and prerelease/build metadata
const match = versionString.match(/^[^\d]*(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
const [, majorStr, minorStr, patchStr] = match;
return {
major: parseInt(majorStr!, 10),
minor: parseInt(minorStr!, 10),
patch: parseInt(patchStr!, 10),
raw: `${majorStr}.${minorStr}.${patchStr}`,
};
}
export function compareVersions(a: VersionTuple, b: VersionTuple): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
/**
* Compares two version tuples and returns true if the current version satisfies the required version.
* @param current - The current version tuple.
* @param required - The required version tuple.
* @returns true if current version is greater than or equal to required version, false otherwise.
*/
export function satisfies(current: VersionTuple, required: VersionTuple): boolean {
return compareVersions(current, required) >= 0;
}
}
export const PackageLspVersion = PackageJSON.dependencies['vscode-languageserver-protocol']!.toString();
export const PackageNodeRequiredVersion = DepVersion.minimumNodeVersion();
/**
* shows last compile bundle time in server cli executable
*/
const getOutTime = () => {
// First check if build time is embedded via environment variable (for bundled version)
if (process.env.FISH_LSP_BUILD_TIME) {
try {
const buildTimeData = JSON.parse(process.env.FISH_LSP_BUILD_TIME);
return buildTimeData.timestamp || new Date(buildTimeData.isoTimestamp).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium' });
} catch (e) {
// If parsing fails, return as-is (fallback for old format)
return process.env.FISH_LSP_BUILD_TIME;
}
}
// Fallback to reading from file (for development version)
const buildFile = getFishBuildTimeFilePath();
try {
const fileContent = readFileSync(buildFile, 'utf8');
const buildTimeData = JSON.parse(fileContent);
return buildTimeData.timestamp || buildTimeData.isoTimestamp;
} catch (e) {
logger.logToStderr(`Error reading build-time file: ${buildFile}`);
logger.error([
`Error reading build-time file: ${buildFile}`,
`Could not read build time from file: ${e}`,
]);
return 'unknown';
}
};
export type BuildTimeJsonObj = {
date: string | Date;
timestamp: string;
isoTimestamp: string;
unix: number;
version: string;
nodeVersion: string;
reproducible?: boolean;
[key: string]: any;
};
export const getBuildTimeJsonObj = (): BuildTimeJsonObj | undefined => {
// First check if build time is embedded via environment variable (for bundled version)
if (process.env.FISH_LSP_BUILD_TIME) {
try {
const jsonObj: BuildTimeJsonObj = JSON.parse(process.env.FISH_LSP_BUILD_TIME);
return { ...jsonObj, date: new Date(jsonObj.date) };
} catch (e) {
logger.logToStderr(`Error parsing embedded build-time JSON: ${e}`);
}
}
// Fallback to reading from file (for development version)
try {
const jsonFile = getFishBuildTimeFilePath();
const jsonContent = readFileSync(jsonFile, 'utf8');
const jsonObj: BuildTimeJsonObj = JSON.parse(jsonContent);
return { ...jsonObj, date: new Date(jsonObj.date) };
} catch (e) {
logger.logToStderr(`Error reading build-time JSON file: ${e}`);
logger.error(`Error reading build-time JSON file: ${e}`);
}
return undefined;
};
export const isPkgBinary = () => {
return typeof __dirname !== 'undefined' ? resolve(__dirname).startsWith('/snapshot/') : false;
};
/**
* Detect if the binary is installed globally by checking if it's accessible via PATH
*/
export const isInstalledGlobally = (): boolean => {
try {
const execPath = getCurrentExecutablePath();
// Check if the executable is in a global npm/yarn installation directory
if (execPath.includes('/node_modules/.bin/') ||
execPath.includes('/.npm/') ||
execPath.includes('/.yarn/') ||
execPath.includes('/usr/local/') ||
execPath.includes('/opt/') ||
execPath.includes('/.local/bin/')) {
return true;
}
// Check if the current executable matches what would be found in PATH
if (process.env.PATH) {
const pathDirs = process.env.PATH.split(':');
for (const dir of pathDirs) {
const potentialPath = resolve(dir, 'fish-lsp');
if (execPath === potentialPath || execPath.startsWith(potentialPath)) {
return true;
}
}
}
return false;
} catch {
return false;
}
};
/**
* Detect the execution context: module, web, binary, or unknown
* Also differentiates between direct execution and node execution
*/
export const getExecutionContext = (): 'module' | 'web' | 'binary' | 'node-binary' | 'node-module' | 'unknown' => {
const execPath = getCurrentExecutablePath();
const isNodeExecution = process.argv[0]?.includes('node');
// Check if running in web context (no real filesystem paths)
if (typeof (globalThis as any).window !== 'undefined' || typeof (globalThis as any).self !== 'undefined') {
return 'web';
}
// Locations where the CLI Binary might be run from
const cliPaths = ['/bin/fish-lsp', '/dist/fish-lsp', '/out/cli.js'];
// Check if running as CLI binary
if (cliPaths.some(path => execPath.endsWith(path))) {
return isNodeExecution ? 'node-binary' : 'binary';
}
// Server/module execution paths
const modulePaths = ['/out/server.js', '/dist/server.js', '/src/server.ts'];
if (modulePaths.some(path => execPath.endsWith(path))) {
return isNodeExecution ? 'node-module' : 'module';
}
// Default to unknown context
return 'unknown';
};
/**
* Generate build type string in format: (local|global) (bundled?) (module|web|binary)
*/
export const getBuildTypeString = (): string => {
const result: string[] = [];
// 1. Installation type: local or global
const installType = isInstalledGlobally() ? 'global' : 'local';
result.push(installType);
// 2. Bundling status: bundled or not
if (isPkgBinary()) {
result.push('pkg-bundle'); // Special case for pkg bundling
} else if (isBundledEnvironment() || getCurrentExecutablePath().includes('/dist/')) {
result.push('bundled');
}
// 3. Execution context: module, web, or binary
const context = getExecutionContext();
result.push(context);
return result.join(' ').trim();
};
export const packageJsonVersion = () => {
return PackageJSON.version || JSON.parse(fs.readFileSync(path.join(getProjectRootPath(), 'package.json'), 'utf8')).version;
};
export const PkgJson = {
...PackageJSON,
name: PackageJSON.name,
version: PackageJSON.version,
description: PackageJSON.description,
npm: 'https://www.npmjs.com/fish-lsp',
repository: PackageJSON.repository?.url.replace(/^git\+/, '') || ' ',
homepage: PackageJSON.homepage || ' ',
lspVersion: PackageLspVersion,
node: PackageNodeRequiredVersion,
man: getManFilePath(),
buildTime: getOutTime(),
buildTimeObj: getBuildTimeJsonObj(),
...PathObj,
};
export const SourcesDict: { [key: string]: string; } = {
repo: 'https://github.com/ndonfris/fish-lsp',
git: 'https://github.com/ndonfris/fish-lsp',
npm: 'https://npmjs.com/fish-lsp',
homepage: 'https://fish-lsp.dev',
contributing: 'https://github.com/ndonfris/fish-lsp/blob/master/docs/CONTRIBUTING.md',
issues: 'https://github.com/ndonfris/fish-lsp/issues?q=',
report: 'https://github.com/ndonfris/fish-lsp/issues?q=',
wiki: 'https://github.com/ndonfris/fish-lsp/wiki',
discussions: 'https://github.com/ndonfris/fish-lsp/discussions',
clientsRepo: 'https://github.com/ndonfris/fish-lsp-language-clients/',
sourceMap: `https://github.com/ndonfris/fish-lsp/releases/download/v${PackageVersion}/sourcemaps.tar.gz`,
sourcesList: [
'https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart',
'https://github.com/microsoft/vscode-extension-samples/tree/main',
'https://tree-sitter.github.io/tree-sitter/',
'https://github.com/ram02z/tree-sitter-fish',
'https://github.com/microsoft/vscode-languageserver-node/tree/main/testbed',
'https://github.com/Beaglefoot/awk-language-server/tree/master/server',
'https://github.com/bash-lsp/bash-language-server/tree/main/server/src',
'https://github.com/oncomouse/coc-fish',
'https://github.com/typescript-language-server/typescript-language-server#running-the-language-server',
'https://github.com/neoclide/coc-tsserver',
'https://www.npmjs.com/package/vscode-jsonrpc',
'https://github.com/Microsoft/vscode-languageserver-node',
'https://github.com/Microsoft/vscode-languageserver-node',
'https://github.com/microsoft/vscode-languageserver-node/blob/main/client/src/common',
'https://github.com/microsoft/vscode-languageserver-node/tree/main/server/src/common',
].join('\n'),
};
export function FishLspHelp() {
const lspV = PackageJSON.dependencies['vscode-languageserver'].toString();
return {
beforeAll: `
fish-lsp [-h | --help] [-v | --version] [--help-man] [--help-all] [--help-short]
fish-lsp start [--enable | --disable] [--dump]
fish-lsp info [--bare] [--repo] [--time] [--env]
fish-lsp url [--repo] [--discussions] [--homepage] [--npm] [--contributions]
[--wiki] [--issues] [--client-repo] [--sources]
fish-lsp env [-c | --create] [-s | --show] [--no-comments]
fish-lsp complete`,
usage: `fish-lsp [OPTION]
fish-lsp [COMMAND [OPTION...]]`,
// fish-lsp [start | logger | info | url | complete] [options]
// fish-lsp [-h | --help] [-v | --version] [--help-man] [--help-all] [--help-short]
description: [
' A language server for the `fish-shell`, written in typescript. Currently supports',
` the following feature set from '${lspV || PackageLspVersion || '^9.0.1'}' of the language server protocol.`,
' More documentation is available for any command or subcommand via \'-h/--help\'.',
'',
' The current language server protocol, reserves stdin/stdout for communication between the ',
' client and server. This means that when the server is started, it will listen for messages on',
' stdin/stdout. Command communication will be visible in `$fish_lsp_log_file`.',
'',
` For more info, please visit: ${chalk.underline('https://github.com/ndonfris/fish-lsp')}`,
].join('\n'),
after: [
'',
'Examples:',
' # Default setup, with all options enabled',
' > fish-lsp start',
'',
' # Generate and store completions file:',
' > fish-lsp complete > ~/.config/fish/completions/fish-lsp.fish',
].join('\n'),
};
}
export function FishLspManPage() {
// Try to get man file from filesystem first (preferred - shows actual install location)
const manFile = PathObj.manFile;
if (manFile && existsSync(manFile)) {
try {
const content = readFileSync(manFile, 'utf8');
return {
path: manFile,
content: content.split('\n'),
};
} catch {
// File exists but can't read it, fall through to VFS
}
}
// Fallback to embedded man page from VFS
if (vfs && vfs.allFiles && Array.isArray(vfs.allFiles)) {
try {
const virtual = vfs.allFiles.find(f => {
return f.filepath.endsWith('man/fish-lsp.1') || f.filepath.endsWith('man/man1/fish-lsp.1');
});
if (virtual && virtual.content) {
// Show warning that we're using embedded version
if (process.stderr.isTTY) {
process.stderr.write('\x1b[33mWarning: Using embedded man page from virtual filesystem\x1b[0m\n');
} else {
process.stderr.write('Warning: Using embedded man page from virtual filesystem\n');
}
return {
path: `${virtual.filepath} (embedded)`,
content: virtual.content.toString().split('\n'),
};
}
} catch (err) {
// VFS access failed, continue to final error
}
}
throw new Error('Man file not available');
}
export function fishLspLogFile() {
const logFile = SyncFileHelper.expandEnvVars(config.fish_lsp_log_file);
if (!logFile) {
logger.error('fish_lsp_log_file is not set in the config file.');
return {
path: '',
content: [],
};
}
const content = SyncFileHelper.read(logFile).split('\n');
return {
path: resolve(logFile),
content: content,
};
}
export namespace CommanderSubcommand {
// Define the subcommands and their schemas
export namespace start {
export const schema = z.record(z.unknown()).and(
z.object({
enable: z.array(z.string()).optional().default([]),
disable: z.array(z.string()).optional().default([]),
dump: z.boolean().optional().default(false),
port: z.string().optional(),
socket: z.string().optional(),
maxFiles: z.string().optional(),
memoryLimit: z.string().optional(),
stdio: z.boolean().optional().default(false),
nodeIpc: z.boolean().optional().default(false),
web: z.boolean().optional().default(false),
}),
);
export type schemaType = z.infer;
export function parse(args: unknown): schemaType {
const isValidArgs = schema.safeParse(args);
return isValidArgs?.success ? isValidArgs.data : schema.parse(args) || defaultSchema; // Validate the args against the schema
}
export const defaultSchema: schemaType = schema.parse({});
}
export namespace info {
export const schema = z.record(z.unknown()).and(
z.object({
bin: z.boolean().optional().default(false),
path: z.boolean().optional().default(false),
buildTime: z.boolean().optional().default(false),
buildType: z.boolean().optional().default(false),
version: z.boolean().optional().default(false),
lspVersion: z.boolean().optional().default(false),
capabilities: z.boolean().optional().default(false),
manFile: z.boolean().optional().default(false),
logFile: z.boolean().optional().default(false),
logsFile: z.boolean().optional().default(false),
show: z.boolean().optional().default(false),
verbose: z.boolean().optional().default(false),
extra: z.boolean().optional().default(false),
healthCheck: z.boolean().optional().default(false),
checkHealth: z.boolean().optional().default(false),
timeStartup: z.boolean().optional().default(false),
timeOnly: z.boolean().optional().default(false),
useWorkspace: z.string().optional().default(''),
warning: z.boolean().optional().default(true),
showFiles: z.boolean().optional().default(false),
sourceMaps: z.boolean().optional().default(false),
check: z.boolean().optional().default(false),
status: z.boolean().optional().default(false),
dumpParseTree: z.union([z.string(), z.boolean()]).optional().default(''),
dumpSemanticTokens: z.union([z.string(), z.boolean()]).optional().default(''),
dumpSymbolTree: z.union([z.string(), z.boolean()]).optional().default(''),
icons: z.boolean().optional().default(true),
color: z.boolean().optional().default(true),
virtualFs: z.boolean().optional().default(false),
}),
);
export type schemaType = z.infer;
export function parse(args: unknown): schemaType {
const isValidArgs = schema.safeParse(args);
return isValidArgs?.success ? isValidArgs.data : schema.parse(args);
}
export const defaultSchema: schemaType = schema.parse({});
export const skippable = z.object({
healthCheck: z.boolean().default(false),
checkHealth: z.boolean().default(false),
timeStartup: z.boolean().default(false),
timeOnly: z.boolean().default(false),
useWorkspace: z.string().default(''),
warning: z.boolean().default(true),
icons: z.boolean().default(true),
color: z.boolean().default(true),
});
export type skippableType = z.infer;
export type skippableArgs = keyof skippableType;
export const parseSkip = (args: unknown): z.infer => {
const isValidArgs = skippable.safeParse(args);
return isValidArgs?.success ? isValidArgs.data : skippable.parse(args) || skippable.parse({}); // Validate the args against the schema
};
export const allSkippableArgvs = [
'info',
'--health-check',
'--check-health',
'--time-startup',
'--time-only',
'--use-workspace',
'--no-warning',
'--show-files',
] as const;
export function handleBadArgs(args: schemaType) {
const argsCount = countArgsWithValues('info', args);
if (args.useWorkspace && args.useWorkspace.length > 0 && !args.timeStartup && !args.timeOnly && argsCount >= 1) {
logger.logToStderr([
buildErrorMessage('ERROR:', 'The option', '--use-workspace', 'should be used with either:', '--time-startup', 'or', '--time-only'),
buildColoredCommandlineString({ subcommand: 'info', args: ['--time-startup', ...commandBin.args.slice(1)] }),
buildErrorMessage(`If you believe this is a bug, please report it at ${chalk.underline.whiteBright(PkgJson.bugs.url)}`),
].join('\n\n'));
process.exit(1);
}
const skippedArgs = commandBin.args.filter(arg => arg.startsWith('--') && allSkippableArgvs.some(skippable => arg.startsWith(skippable)));
const unrelatedArgs = commandBin.args.filter(arg => arg.startsWith('--') && !allSkippableArgvs.some(skippable => arg.startsWith(skippable)));
if (skippedArgs.length > 0 && unrelatedArgs.length > 0) {
const unrelatedArgsSeen = argsToString(unrelatedArgs);
logger.logToStderr([
buildErrorMessage('ERROR:', 'Incompatible arguments provided.'),
buildErrorMessage('FIXES:', 'Try removing the invalid arguments provided and running the command again.', 'INVALID ARGUMENTS:', ...unrelatedArgsSeen.replaceAll('"', '').split(', ')),
buildColoredCommandlineString({ subcommand: 'info', args: skippedArgs }),
buildErrorMessage(`If you believe this is a bug, please report it at ${chalk.underline.whiteBright(PkgJson.bugs.url)}`),
].join('\n\n'));
process.exit(1);
}
}
export function handleFileArgs(args: schemaType) {
const seenArgs = keys(args).filter(k => ['manFile', 'logFile', 'logsFile'].includes(k));
const otherArgs = keys(args).filter(k => !['manFile', 'logFile', 'logsFile', 'show'].includes(k));
const argsCount = otherArgs.length >= 1 ? otherArgs.length + 1 + seenArgs.length : otherArgs.length + seenArgs.length || 0;
const hasLogFile = args.logFile || args.logsFile;
const hasManFile = args.manFile;
const hasShowFlag = args.show;
if (hasLogFile) {
const logObj = fishLspLogFile();
const title = 'Log File';
const message = args.show ? logObj.content.join('\n') : logObj.path;
log(argsCount, title, message);
}
if (hasManFile) {
try {
const manObj = FishLspManPage();
const title = 'Man File';
const message = args.show ? manObj.content.join('\n') : manObj.path;
if (manObj.content && manObj.path.startsWith('/man')) {
logger.logToStderr('\x1b[33mWarning: Displaying embedded\x1b[0m');
log(argsCount, title + ' (embedded)', manObj.content.join('\n'));
return;
}
log(argsCount, title, message);
} catch (error) {
log(argsCount, 'Man File', 'Error: Man file not available');
}
}
if (!hasLogFile && !hasManFile && hasShowFlag) {
logger.logToStderr([
'ERROR: flag `--show` requires either `--log-file` or `-man-file`',
'fish-lsp info [--log-file | --man-file] --show',
].join('\n'));
return 1;
}
return 0;
}
// Show output for the sourcemaps switch
export function handleSourceMaps(args: schemaType) {
let exitStatus = 0;
if (!args.sourceMaps) return exitStatus;
// check if all sourcemaps are present
Object.values(SourceMaps).forEach(v => {
if (!fs.existsSync(v) && !fs.readFileSync(getCurrentExecutablePath()).includes('//# sourceMappingURL=')) {
exitStatus = 1;
}
});
const showSourceMaps = () => {
logger.logToStdout('-'.repeat(maxWidthForOutput())); // Add a blank line between maps
const hasExternalSourceMaps = () => {
for (const v of Object.values(SourceMaps)) {
if (fs.existsSync(v)) return true;
}
return false;
};
if (!hasExternalSourceMaps() && fs.readFileSync(getCurrentExecutablePath()).includes('//# sourceMappingURL=')) {
logger.logToStdoutJoined(chalk.white.bold('Inline Sourcemaps:'), ' ', chalk.blue(getCurrentExecutablePath().replace(homedir(), '~')));
} else {
for (const [k, v] of Object.entries(SourceMaps)) {
const exists = fs.existsSync(v);
logger.logToStdoutJoined(`${chalk.white('Sourcemap \'')}`, chalk.blue(k), chalk.white("': "), exists ? chalk.green('✅ Available') : chalk.red('❌ Not found'));
if (exists) {
logger.logToStdout(`${chalk.white('Path:')} ${chalk.blue(v.replace(homedir(), '~'))}`);
} else {
logger.logToStdout(`${chalk.white('Path:')} ${chalk.blue(v.replace(homedir(), '~'))} ${chalk.red('(not found)')}`);
}
}
}
logger.logToStdout('-'.repeat(maxWidthForOutput())); // Add a blank line between maps
};
if (args.all && !args.allPaths) {
logger.logToStdout('-'.repeat(maxWidthForOutput()));
sourcemaps().split('\n').forEach(line => {
if (line.includes(' (embedded inline)')) {
logger.logToStdoutJoined(chalk.white('Sourcemaps are embedded in the binary at:'), ' ', chalk.blue(line.replace(' (embedded inline)', '').replace(homedir(), '~')));
return 0;
}
const exists = fs.existsSync(line);
logger.logToStdoutJoined(`${chalk.white('Sourcemap at path \'')}`, chalk.blue(line.replace(homedir(), '~')), chalk.white("': "), exists ? chalk.green('✅ Available') : chalk.red('❌ Not found'));
});
logger.logToStdout('-'.repeat(maxWidthForOutput())); // Add a blank line between maps
return exitStatus;
}
if (args.allPaths) {
sourcemaps().split('\n').forEach(line => {
if (line.includes(' (embedded inline)')) {
logger.logToStdout(line.replace(' (embedded inline)', ''));
} else {
logger.logToStdout(line);
}
});
return exitStatus;
}
if (args.check) {
showSourceMaps();
try {
FishServer.throwError('Source map checking `fish-lsp info --source-maps --check`');
} catch (error) {
logger.logToStdoutJoined(chalk.dim('(Should throw error) '), chalk.red.underline.bold('Sourcemap check:'), ' ', chalk.red.dim((error as Error).message));
FishServer.throwError('Source map checking passed `fish-lsp info --source-maps --check`');
exitStatus = 1;
}
return exitStatus;
}
if (args.status) {
sourcemaps().split('\n').forEach(line => {
if (line.includes(' (embedded inline)')) {
logger.logToStdoutJoined(line.split(' ').at(0)!, ' ', chalk.blue('(embedded inline)'));
} else {
logger.logToStdoutJoined(line, ' ', fs.existsSync(line) ? chalk.green('(available)') : chalk.red('(not found)'));
}
});
return exitStatus;
}
// Default source map path
showSourceMaps();
return exitStatus;
}
export function sourcemaps() {
const result: string[] = [];
if (fs.readFileSync(getCurrentExecutablePath()).includes('//# sourceMappingURL=')) {
result.push(getCurrentExecutablePath() + ' (embedded inline)');
} else {
for (const v of Object.values(SourceMaps)) {
if (fs.existsSync(v)) {
result.push(v);
}
}
}
return result.join('\n');
}
export function log(argsCount: number, title: string, message: string, alwaysShowTitle = false) {
const isCapabilitiesString = title.toLowerCase() === 'capabilities';
if (isCapabilitiesString) message = `\n${message}`;
if (argsCount > 1 || alwaysShowTitle || isCapabilitiesString) {
logger.logToStdout(`${chalk.whiteBright.bold(`${title}:`)} ${chalk.cyan(message)}`);
} else {
logger.logToStdout(`${message}`);
}
}
}
export namespace url {
export const schema = z.record(z.unknown()).and(
z.object({
repo: z.boolean().optional().default(false),
discussions: z.boolean().optional().default(false),
homepage: z.boolean().optional().default(false),
npm: z.boolean().optional().default(false),
contributions: z.boolean().optional().default(false),
wiki: z.boolean().optional().default(false),
issues: z.boolean().optional().default(false),
clientRepo: z.boolean().optional().default(false),
sources: z.boolean().optional().default(false),
sourceMap: z.boolean().optional().default(false),
}),
);
export type schemaType = z.infer;
export function parse(args: unknown): schemaType {
const isValidArgs = schema.safeParse(args);
return isValidArgs?.success ? isValidArgs.data : schema.parse(args) || defaultSchema; // Validate the args against the schema
}
export const defaultSchema: schemaType = schema.parse({});
}
export namespace complete {
export const schema = z.record(z.unknown()).and(
z.object({
names: z.boolean().optional().default(false),
namesWithSummary: z.boolean().optional().default(false),
fish: z.boolean().optional().default(false),
toggles: z.boolean().optional().default(false),
features: z.boolean().optional().default(false),
envVariables: z.boolean().optional().default(false),
envVariablesNames: z.boolean().optional().default(false),
abbreviations: z.boolean().optional().default(false),
}),
);
export type schemaType = z.infer;
export function parse(args: unknown): schemaType {
const isValidArgs = schema.safeParse(args);
return isValidArgs?.success ? isValidArgs.data : schema.parse(args) || defaultSchema; // Validate the args against the schema
}
export const defaultSchema: schemaType = schema.parse({});
}
export namespace env {
export const schema = z.record(z.unknown()).and(
z.object({
create: z.boolean().optional().default(false),
show: z.boolean().optional().default(false),
showDefault: z.boolean().optional().default(false),
only: z.union([z.string(), z.array(z.string())]).optional(),
comments: z.boolean().optional().default(true),
global: z.boolean().optional().default(true),
local: z.boolean().optional().default(false),
export: z.boolean().optional().default(true),
confd: z.boolean().optional().default(false),
names: z.boolean().optional().default(false),
joined: z.boolean().optional().default(false),
}),
);
export type schemaType = z.infer;
export function parse(args: unknown): schemaType {
const isValidArgs = schema.safeParse(args);
return isValidArgs?.success ? isValidArgs.data : schema.parse(args) || defaultSchema; // Validate the args against the schema
}
export const defaultSchema: schemaType = schema.parse({});
}
export const subcommands = [
'start',
'info',
'url',
'env',
'complete',
] as const;
export type SubcommandType = (typeof subcommands)[number];
export type schemas = typeof start.schema
| typeof info.schema
| typeof url.schema
| typeof env.schema
| typeof complete.schema;
export const allSchemas = z.object({
start: start.schema,
info: info.schema,
url: url.schema,
env: env.schema,
complete: complete.schema,
});
export function parseSubcommand(command: SubcommandType, args: unknown): z.infer {
switch (command) {
case 'start':
return start.schema.parse(args);
case 'info':
return info.schema.parse(args);
case 'url':
return url.schema.parse(args);
case 'env':
return env.schema.parse(args);
case 'complete':
return complete.schema.parse(args);
default:
throw new Error(`Unknown subcommand: ${command}`);
}
}
export const getSchemaKeys = (schema: typeof allSchemas) => {
// return schema.keyof()?._def.values;
return [...schema.keyof().options];
};
export function hasSkippable(command: SubcommandType) {
switch (command) {
case 'info':
return true;
// No skippable
case 'start':
case 'complete':
case 'url':
case 'env':
return false;
default:
throw new Error(`Unknown subcommand: ${command}`);
}
}
export function getSubcommand(command: SubcommandType): schemas {
switch (command) {
case 'start':
return start.schema;
case 'info':
return info.schema;
case 'url':
return url.schema;
case 'env':
return env.schema;
case 'complete':
return complete.schema;
default:
throw new Error(`Unknown subcommand: ${command}`);
}
}
export function countArgsWithValues(subcommand: SubcommandType, args: Record): number {
const keysToCount = getSubcommand(subcommand).parse(args);
const results: Record = {};
const skippableArgs = hasSkippable(subcommand);
const removed: Record = {};
if (skippableArgs) {
const skippable = info.skippable.parse(args);
const defaults = info.skippable.parse({});
for (const key in skippable) {
if (key === subcommand) removed[key] = true;
removed[key] = true;
if (skippable[key as keyof typeof skippable] !== defaults[key as keyof typeof defaults]) {
removed[key] = false;
}
}
}
for (const key in keysToCount) {
if (key === subcommand) removed[key] = true;
if (removed[key]) continue;
if (keysToCount[key]) results[key] = true;
}
return Object.keys(results).length;
}
export function removeArgs(args: { [k: string]: unknown; }, ...keysToRemove: string[]) {
const argKeys = keys(args);
return argKeys.filter((key) => !keysToRemove.includes(key));
}
export const countArgs = (args: any): number => {
return keys(args).length;
};
export const keys = (args: { [k: string]: unknown; }) => {
return Object.entries(args)
.filter(([key, value]) => !!key && !!value && !(key === 'warning' && value === true))
.map(([key, _]) => key);
};
export function entries(args: any) {
return Object.entries(args)
.filter(([key, value]) => !!key && !!value && !(key === 'warning' && value === true))
.map(([key, _]) => key);
}
export function noArgs(args: any): boolean {
return Object.keys(args).length === 0;
}
export function argsToString(args: { [k: string]: unknown; } | string[]): string {
if (Array.isArray(args)) {
return args.map(m => ['', m, ''].join('"')).join(', ');
}
return Object.keys(args).map(m => ['', m, ''].join('"')).join(', ');
}
export function buildErrorMessage(...stdin: string[]) {
return stdin.map((item, idx) => {
const splitItem = item.split(' ');
return splitItem.map((part) => {
if (idx === 0 && part.toUpperCase() === part) {
return chalk.bold.red(part);
}
if (part.startsWith('--')) {
return chalk.whiteBright(part);
}
return chalk.redBright(part);
}).join(' ');
}).join(' ');
}
export type CommandlineOpts = {
subcommand: 'start' | 'info' | 'url' | 'env' | 'complete' | '';
args?: string[];
prefixIndent?: boolean;
showPrompt?: boolean;
};
// default values for commandline options
const commandlineOpts = {
subcommand: '',
args: [],
prefixIndent: true,
showPrompt: true,
} as CommandlineOpts;
export function buildColoredCommandlineString(opts: CommandlineOpts): string {
// set the default values if not provided
if (opts.prefixIndent === undefined) opts.prefixIndent = commandlineOpts.prefixIndent;
if (opts.showPrompt === undefined) opts.showPrompt = commandlineOpts.showPrompt;
const result: string[] = [];
// format the initial part of the command line
if (opts.prefixIndent) result.push(' ');
if (opts.showPrompt) {
if (result.length > 0) {
result[0] = result[0] + chalk.whiteBright('>_');
} else {
result.push(chalk.whiteBright('>_'));
}
}
// add the command and subcommand
result.push(chalk.magenta('fish-lsp'));
result.push(chalk.blue(opts.subcommand));
// add the args if provided
if (opts.args && opts.args.length > 0) {
opts.args.forEach((arg: string) => {
const toAddArg = arg.replaceAll(/"/g, '');
if (toAddArg.includes('=')) {
const [key, value] = toAddArg.split('=');
result.push(`${chalk.white(key)}${chalk.bold.cyan('=')}${chalk.green(value)}`);
} else {
result.push(chalk.white(toAddArg));
}
});
}
// join the result with spaces
return result.join(' ');
}
}
export function BuildCapabilityString() {
const done = '✔️ '; // const done: string = '✅'
const todo = '❌'; // const todo: string = '❌'
const statusString = [
`${done} complete`,
`${done} hover`,
`${done} rename`,
`${done} definition`,
`${done} references`,
`${done} diagnostics`,
`${done} signatureHelp`,
`${done} codeAction`,
`${todo} codeLens`,
`${done} documentLink`,
`${done} formatting`,
`${done} rangeFormatting`,
`${done} refactoring`,
`${done} executeCommand`,
`${done} workspaceSymbol`,
`${done} documentSymbol`,
`${done} foldingRange`,
`${done} fold`,
`${done} onType`,
`${done} onDocumentSaveFormat`,
`${done} onDocumentSave`,
`${done} onDocumentOpen`,
`${done} onDocumentChange`,
`${todo} semanticTokens`,
].join('\n');
return statusString;
}
/**
* Record of the sourcemaps for each file in the project.
*/
export const SourceMaps: Record = {
'dist/fish-lsp': path.resolve(path.dirname(getCurrentExecutablePath()), '..', 'dist', 'fish-lsp.map'),
};
================================================
FILE: src/utils/completion/comment-completions.ts
================================================
import { Command, Position, Range, TextEdit } from 'vscode-languageserver';
import { FishCommandCompletionItem, FishCompletionData } from './types';
import { StaticItems } from './static-items';
import { SyntaxNode } from 'web-tree-sitter';
import { DIAGNOSTIC_COMMENT_REGEX, DiagnosticAction, isValidErrorCode } from '../../diagnostics/comments-handler';
import { FishCompletionList } from './list';
import { SetupData } from './pager';
import { ErrorCodes } from '../../diagnostics/error-codes';
export function buildCommentCompletions(
line: string,
position: Position,
node: SyntaxNode,
data: SetupData,
word: string,
) {
// FishCompletionItem.createData(data.uri, line, ,detail, documentation)
const hashIndex = line.indexOf('#');
// Create range from the # character to cursor
const range = Range.create(
Position.create(position.line, hashIndex),
position,
);
// Command to retrigger completion
const retriggerCommand: Command = {
title: 'Suggest',
command: 'editor.action.triggerSuggest',
};
const completions: FishCommandCompletionItem[] = [];
if (position.line === 0) {
completions.push(
...StaticItems.shebang.map(item => {
item.textEdit = TextEdit.replace(range, item.label);
return item;
}),
);
}
/**
* add diagnostic comment strings:
* `# @fish-lsp-disable`
*/
const diagnosticComment = getCommentDiagnostics(line, position.line);
if (!diagnosticComment) {
completions.push(
...StaticItems.comment.map((item) => {
item.textEdit = TextEdit.replace(range, `${item.label} `);
item.command = retriggerCommand;
return item;
}));
}
/**
* add diagnostic codes to the completion list
* `# @fish-lsp-disable 1001`
*/
if (diagnosticComment) {
if (diagnosticComment?.codes) {
const codeStrings = diagnosticComment?.codes.map(code => code.toString());
completions.push(
...StaticItems.diagnostic
.filter(item => !codeStrings.includes(item.label))
.map((item) => {
item.command = retriggerCommand;
item.insertText = `${item.label} `;
return item;
}),
);
}
}
const completionData: FishCompletionData = {
word,
position,
uri: data.uri,
line,
};
return FishCompletionList.create(false, completionData, completions);
}
function getCommentDiagnostics(line: string, lineNumber: number) {
const match = line.trim().match(DIAGNOSTIC_COMMENT_REGEX);
if (!match) return null;
const [, action, nextLine, codesStr] = match;
const codeStrings = codesStr ? codesStr.trim().split(/\s+/) : [];
// Parse the diagnostic codes if present
const parsedCodes = codeStrings
.map(codeStr => parseInt(codeStr, 10))
.filter(code => !isNaN(code));
const validCodes: ErrorCodes.CodeTypes[] = [];
const invalidCodes: string[] = [];
codeStrings.forEach((codeStr, idx) => {
const code = parsedCodes[idx];
if (code && !isNaN(code) && isValidErrorCode(code)) {
validCodes.push(code as ErrorCodes.CodeTypes);
} else {
invalidCodes.push(codeStr);
}
});
return {
action: action as DiagnosticAction,
target: nextLine ? 'next-line' : 'line',
codes: validCodes,
lineNumber: lineNumber,
invalidCodes: invalidCodes.length > 0 ? invalidCodes : undefined,
};
}
================================================
FILE: src/utils/completion/documentation.ts
================================================
import { MarkupContent } from 'vscode-languageserver';
import { FishCompletionItem, FishCompletionItemKind, CompletionExample } from './types';
import { execCmd, execCommandDocs } from '../exec';
import { md } from '../markdown-builder';
export async function getDocumentationResolver(item: FishCompletionItem): Promise {
let docString: string = item.documentation.toString();
if (!item.local) {
switch (item.fishKind) {
case FishCompletionItemKind.ABBR:
docString = await getAbbrDocString(item.label) ?? docString;
break;
case FishCompletionItemKind.ALIAS:
docString = await getAliasDocString(item.label, item.documentation.toString() || `alias ${item.label}`) ?? docString;
break;
case FishCompletionItemKind.COMBINER:
case FishCompletionItemKind.STATEMENT:
case FishCompletionItemKind.BUILTIN:
docString = await getBuiltinDocString(item.label) ?? docString;
break;
case FishCompletionItemKind.COMMAND:
docString = await getCommandDocString(item.label) ?? docString;
break;
case FishCompletionItemKind.FUNCTION:
docString = await getFunctionDocString(item.label) ?? `(${md.bold('function')}) ${item.label}`;
break;
case FishCompletionItemKind.VARIABLE:
docString = await getVariableDocString(item.label) ?? docString;
break;
case FishCompletionItemKind.EVENT:
docString = await getEventHandlerDocString(item.documentation as string) ?? docString;
break;
case FishCompletionItemKind.COMMENT:
case FishCompletionItemKind.SHEBANG:
case FishCompletionItemKind.DIAGNOSTIC:
docString = item.documentation.toString();
break;
case FishCompletionItemKind.STATUS:
case FishCompletionItemKind.WILDCARD:
case FishCompletionItemKind.REGEX:
case FishCompletionItemKind.FORMAT_STR:
case FishCompletionItemKind.ESC_CHARS:
case FishCompletionItemKind.PIPE:
docString ??= await getStaticDocString(item as FishCompletionItem);
break;
case FishCompletionItemKind.ARGUMENT:
docString = await buildArgumentDocString(item);
break;
case FishCompletionItemKind.EMPTY:
default:
break;
}
}
if (item.local) {
return {
kind: 'markdown',
value: item.documentation.toString(),
} as MarkupContent;
}
return {
kind: 'markdown',
value: docString,
} as MarkupContent;
}
/**
* builds FunctionDocumentation string
*/
export async function getFunctionDocString(name: string): Promise {
function formatTitle(title: string[]) {
const ensured = ensureMinLength(title, 5, '');
const [path, autoloaded, line, scope, description] = ensured;
return [
`__\`${path}\`__`,
`- autoloaded: ${autoloaded === 'autoloaded' ? '_true_' : '_false_'}`,
`- line: _${line}_`,
`- scope: _${scope}_`,
`${description}`,
].map((str) => str.trim()).filter(l => l.trim().length).join('\n');
}
const [title, body] = await Promise.all([
execCmd(`functions -D -v ${name}`),
execCmd(`functions --no-details ${name}`),
]);
return [
formatTitle(title),
'___',
'```fish',
body.join('\n'),
'```',
].join('\n') || '';
}
export async function getStaticDocString(item: FishCompletionItem): Promise {
let result = [
'```text',
`${item.label} - ${item.documentation}`,
'```',
].join('\n');
item.examples?.forEach((example: CompletionExample) => {
result += [
'___',
'```fish',
`# ${example.title}`,
example.shellText,
'```',
].join('\n');
});
return result;
}
async function buildArgumentDocString(item: FishCompletionItem): Promise {
if (!item.detail) {
return md.codeBlock('fish', item.documentation.toString());
}
return [
md.codeBlock('fish', item.documentation.toString()),
md.separator(),
item.detail,
].join('\n');
}
export async function getAbbrDocString(name: string): Promise {
const items: string[] = await execCmd('abbr --show | string split \' -- \' -m1 -f2');
function getAbbr(items: string[]): [ string, string ] {
const start: string = `${name} `;
for (const item of items) {
if (item.startsWith(start)) {
return [start.trimEnd(), item.slice(start.length)];
}
}
return ['', ''];
}
const [title, body] = getAbbr(items);
return [
`Abbreviation: \`${title}\``,
'___',
'```fish',
body.trimEnd(),
'```',
].join('\n') || '';
}
/**
* builds MarkupString for builtin documentation
*/
export async function getBuiltinDocString(name: string): Promise {
const cmdDocs: string = await execCommandDocs(name);
if (!cmdDocs) {
return undefined;
}
const splitDocs = cmdDocs.split('\n');
const startIndex = splitDocs.findIndex((line: string) => line.trim() === 'NAME');
return [
`__${name.toUpperCase()}__ - _https://fishshell.com/docs/current/cmds/${name.trim()}.html_`,
'___',
'```man',
splitDocs.slice(startIndex).join('\n'),
'```',
].join('\n');
}
export async function getAliasDocString(label: string, line: string): Promise {
return [
`Alias: _${label}_`,
'___',
'```fish',
line.split('\t')[1],
'```',
].join('\n');
}
/**
* builds MarkupString for event handler documentation
*/
export async function getEventHandlerDocString(documentation: string): Promise {
const [label, ...commandArr] = documentation.split(/\s/, 2);
const command = commandArr.join(' ');
const doc = await getFunctionDocString(command);
if (!doc) {
return [
`Event: \`${label}\``,
'___',
`Event handler for \`${command}\``,
].join('\n');
}
return [
`Event: \`${label}\``,
'___',
doc,
].join('\n');
}
/**
* builds MarkupString for global variable documentation
*/
export async function getVariableDocString(name: string): Promise {
const vName = name.startsWith('$') ? name.slice(name.lastIndexOf('$')) : name;
const out = await execCmd(`set --show --long ${vName}`);
const { first, middle, last } = out.reduce((acc, curr, idx, arr) => {
if (idx === 0) {
acc.first = curr;
} else if (idx === arr.length - 1) {
acc.last = curr;
} else {
acc.middle.push(curr);
}
return acc;
}, { first: '', middle: [] as string[], last: '' });
return [
first,
'___',
middle.join('\n'),
'___',
last,
].join('\n');
}
export async function getCommandDocString(name: string): Promise