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 ================================================

fish-lsp fish-lsp
GitHub Actions Workflow Status License Github Created At NPM Downloads

![demo.gif](https://github.com/ndonfris/fish-lsp.dev/blob/ndonfris-patch-1/new_output.gif?raw=true) Introducing the [fish-lsp](https://fish-lsp.dev), a [Language Server Protocol (LSP)](https://lsif.dev/) implementation for the [fish shell language](https://fishshell.com). ## Quick Start Please choose a [method to install](#installation) the language server and [configure a client](#client-configuration-required) to use `fish-lsp` in your editor. A detailed explanation of how a language server connection works is described on the following [wiki](https://github.com/ndonfris/fish-lsp/wiki/How-does-it-work%3F) page. ## Why? 🐟 - 🦈 __Efficiency__: enhances the shell scripting experience with an extensive suite of intelligent text-editing [features](#features) - 🐡 __Flexibility__: allows for a highly customizable [configuration](#server-configuration-optional) - 🐚 __Guidance__: [friendly hints](https://github.com/ndonfris/fish-lsp/?tab=readme-ov-file#) and [documentation](#installation) to comfortably explore command line tooling - 🐬 __Community__: improved by a [vibrant user base](#contributors), with [supportive and insightful feedback](https://github.com/ndonfris/fish-lsp/discussions) - 🐙 __Compatibility__: integrates with a wide variety of [tooling and language clients](#client-configuration-required) - 🌊 __Reliability__: produces an [editor agnostic developer environment](https://en.wikipedia.org/wiki/Language_Server_Protocol), ensuring __all__ fish user's have access to a consistent set of features ## Features | Feature | Description | Status | | --- | --- | --- | | __Completion__ | Provides completions for commands, variables, and functions | ✅ | | __Hover__ | Shows documentation for commands, variables, and functions. Has special handlers for --flag, commands, functions, and variables | ✅ | | __Signature Help__ | Shows the signature of a command or function | ✅ | | __Goto Definition__ | Jumps to the definition of a command, variable, function or --flag | ✅ | | __Goto Implementation__ | Jumps between symbol definitions and completion definitions | ✅ | | __Find References__ | Shows all references to a command, variable, function, or --flag | ✅ | | __Rename__ | Rename within _matching_ __global__ && __local__ scope | ✅ | | __Document Symbols__ | Shows all commands, variables, and functions in a document | ✅ | | __Workspace Symbols__ | Shows all commands, variables, and functions in a workspace | ✅ | | __Document Formatting__ | Formats a document, _full_ & _selection_ | ✅ | | __On Type Formatting__ | Formats a document while typing | ✅ | | __Document Highlight__ | Highlights all references to a command, variable, or function. | ✅ | | __Command Execution__ | Executes a server command from the client | ✅ | | __Code Action__ | Automate code generation | ✅ | | __Quick fix__ | Auto fix lint issues | ✅ | | __Inlay Hint__ | Shows Virtual Text/Inlay Hints | ✅ | | __Code Lens__ | Shows all available code lenses | ✖ | | __Logger__ | Logs all server activity | ✅ | | __Diagnostic__ | Shows all diagnostics | ✅ | | __Folding Range__ | Toggle ranges to fold text | ✅ | | __Selection Range__ | Expand ranges when selecting text | ✅ | | __Semantic Tokens__ | Server provides extra syntax highlighting | ✅ | | __CLI Interactivity__ | Provides a CLI for server interaction.
Built by `fish-lsp complete` | ✅ | | __Client Tree__ | Shows the defined scope as a Tree | ✅ | | __Indexing__ | Indexes all commands, variables, functions, and source files | ✅ | ## Installation Some language clients might support downloading the fish-lsp directly from within the client, but for the most part, you'll typically be required to install the language server manually. Below are a few methods to install the language server, and how to verify that it's working. ### Use a Package Manager Stability across package managers can vary. Consider using another installation method if issues arise. ```bash npm install -g fish-lsp yarn global add fish-lsp pnpm install -g fish-lsp nix-shell -p fish-lsp brew install fish-lsp conda install fish-lsp mamba install fish-lsp ``` You can install the completions by running the following command: ```fish fish-lsp complete > ~/.config/fish/completions/fish-lsp.fish ``` ### Download Standalone Binary Install the standalone binary directly from GitHub releases (no dependencies required): ```bash # Download the latest standalone binary curl -L https://github.com/ndonfris/fish-lsp/releases/latest/download/fish-lsp.standalone \ -o ~/.local/bin/fish-lsp # Make it executable chmod +x ~/.local/bin/fish-lsp # Install completions fish-lsp complete > ~/.config/fish/completions/fish-lsp.fish ``` > __Note:__ > Ensure `~/.local/bin` is in your `$PATH`. ### Build from Source Recommended Dependencies: `yarn@1.22.22` `node@22.14.0` `fish@4.0.8` ```bash git clone https://github.com/ndonfris/fish-lsp cd fish-lsp/ yarn install yarn build # links `./dist/fish-lsp` to `yarn global bin` $PATH ``` Building the project from source is the most portable method for installing the language server. ### Verifying Installation After installation, verify that `fish-lsp` is working correctly: ```bash fish-lsp --help ``` ![fish-lsp --help](https://github.com/ndonfris/fish-lsp.dev/blob/master/public/help-msg-new.png?raw=true) ## Setup To properly configure [fish-lsp](https://fish-lsp.dev), you need to define a client configuration after installing the language server. Configuring a client should be relatively straightforward. Typically, you're only required to translate the shell command `fish-lsp start` for `fish` files, in the [client's configuration](#client-configuration-required). However, further configuration can be specified as a [server configuration](#server-configuration-optional). Some clients may also allow specifying the server configuration directly in the client configuration. ### Client Configuration (Required) Theoretically, the language-server should generally be compatible with almost any text-editor or IDE you prefer using. Feel free to setup the project in any [fish-lsp-client](https://github.com/ndonfris/fish-lsp/wiki/Client-Configurations) of your choice, or [submit a PR](https://github.com/ndonfris/fish-lsp-language-clients/pulls) for new configurations.
neovim (minimum version >= v0.8.x) Full table of options available in the [neovim documentation](https://neovim.io/doc/user/lsp.html) ```lua vim.api.nvim_create_autocmd('FileType', { pattern = 'fish', callback = function() vim.lsp.start({ name = 'fish-lsp', cmd = { 'fish-lsp', 'start' }, }) end, }) ``` Alternatively, you can also see official documentation for [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig/blob/master/doc/configs.md#fish_lsp), or use your client of choice below. > There is also a useful configuration for testing out the language server in `nvim@v0.11.1` included in the [fish-lsp-language-clients](https://github.com/ndonfris/fish-lsp-language-clients/tree/packer) repository.
mason.nvim Install the `fish-lsp` using [mason.nvim](https://github.com/mason-org/mason-registry/pull/8609#event-18154473712) ```vimscript :MasonInstall fish-lsp ```
coc.nvim [Neovim](https://neovim.io) client using [coc.nvim](https://github.com/neoclide/coc.nvim) configuration, located inside [coc-settings.json](https://github.com/neoclide/coc.nvim/wiki/Language-servers#register-custom-language-servers) `"languageserver"` key ```json { "fish-lsp": { "command": "fish-lsp", "filetypes": ["fish"], "args": ["start"] } } ```
YouCompleteMe [YouCompleteMe](https://github.com/ycm-core/YouCompleteMe) configuration for vim/neovim ```vim let g:ycm_language_server = \ [ \ { \ 'name': 'fish', \ 'cmdline': [ 'fish-lsp', 'start' ], \ 'filetypes': [ 'fish' ], \ } \ ] ```
vim-lsp Configuration of [prabirshrestha/vim-lsp](https://github.com/prabirshrestha/vim-lsp) in your `init.vim` or `init.lua` file ```vim if executable('fish-lsp') au User lsp_setup call lsp#register_server({ \ 'name': 'fish-lsp', \ 'cmd': {server_info->['fish-lsp', 'start']}, \ 'allowlist': ['fish'], \ }) endif ```
helix In config file `~/.config/helix/languages.toml` ```toml [[language]] name = "fish" language-servers = [ "fish-lsp" ] [language-server.fish-lsp] command = "fish-lsp" args= ["start"] environment = { "fish_lsp_show_client_popups" = "false" } ```
kakoune Configuration for [kakoune-lsp](https://github.com/kakoune-lsp/kakoune-lsp), located in `~/.config/kak-lsp/kak-lsp.toml` ```toml [language.fish] filetypes = ["fish"] command = "fish-lsp" args = ["start"] ``` Or in your `~/.config/kak/lsp.kak` file ```kak hook -group lsp-filetype-fish global BufSetOption filetype=fish %{ set-option buffer lsp_servers %{ [fish-lsp] root_globs = [ "*.fish", "config.fish", ".git", ".hg" ] args = [ "start" ] } } ```
kate Configuration for [kate](https://kate-editor.org/) ```json { "servers": { "fish": { "command": ["fish-lsp", "start"], "url": "https://github.com/ndonfris/fish-lsp", "highlightingModeRegex": "^fish$" } } } ```
emacs Configuration using [eglot](https://github.com/joaotavora/eglot) (Built into Emacs 29+) ```elisp ;; Add to your init.el or .emacs (require 'eglot) (add-to-list 'eglot-server-programs '(fish-mode . ("fish-lsp" "start"))) ;; Optional: auto-start eglot for fish files (add-hook 'fish-mode-hook 'eglot-ensure) ``` or place in your `languages/fish.el` file ```elisp (use-package fish-mode) (with-eval-after-load 'eglot (add-to-list 'eglot-server-programs '(fish-mode . ("fish-lsp" "start")))) ``` Configuration using [lsp-mode](https://github.com/emacs-lsp/lsp-mode) ```elisp ;; Add to your init.el or .emacs (require 'lsp-mode) (lsp-register-client (make-lsp-client :new-connection (lsp-stdio-connection '("fish-lsp" "start")) :activation-fn (lsp-activate-on "fish") :server-id 'fish-lsp)) ;; Optional: auto-start lsp for fish files (add-hook 'fish-mode-hook #'lsp) ``` Full example configuration using [doom-emacs](https://github.com/doomemacs/doomemacs/tree/master) can be found in the [fish-lsp language clients repo](https://github.com/ndonfris/fish-lsp-language-clients/).
VSCode/VSCodium (Source Code Repo) > For VSCode, visit the extension on the [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=ndonfris.fish-lsp). > For VSCodium, visit the extension on the [OpenVSX Marketplace](https://open-vsx.org/extension/ndonfris/fish-lsp). > > Using either of these editors/extensions, should allow the `fish-lsp` to work out-of-the-box with minimal configuration (no client configuration is required). > > A server configuration can still be specified to control the server's behavior. ([see below](#server-configuration-optional))
BBEdit > To install the fish-lsp in [BBEdit](https://www.barebones.com/products/bbedit/), please follow the instructions in the repository [fish-lsp-language-clients](https://github.com/ndonfris/fish-lsp-language-clients/blob/bbedit/BBEdit%20Install.md). > > This configuration includes a [Fish.plist](https://github.com/ndonfris/fish-lsp-language-clients/blob/bbedit/Lanugage%20Modules/Fish.plist) file that provides syntax highlighting and other features for the fish shell.
IntelliJ > To install the fish-lsp in [IntelliJ](https://www.jetbrains.com/idea/), please follow the instructions in the repository [jetbrains-fish](https://github.com/tox-dev/jetbrains-fish?tab=readme-ov-file#installation).
### Server Configuration (Optional) Specific functionality for the server can be set independently from the client. The server allows for both [environment variables](#environment-variables) and [command flags](#command-flags) to customize how specific server processes are started. #### Environment variables Environment variables provide a way to globally configure the server across all sessions, but can be overridden interactively[\[1\]](https://fishshell.com/docs/current/language.html#variable-scope) by the current shell session as well. They can easily be auto-generated[\[1\]](#environment-variables-default)[\[2\]](#environment-variables-template)[\[3\]](#environment-variables-json)[\[4\]](#environment-variables-confd) for multiple different use cases using the `fish-lsp env` command. You can store them directly in your `config.fish` to be autoloaded for every fish session. Or if you prefer a more modular approach, checkout the [`--confd`](#environment-variables-confd) flag which will structure the autoloaded environment variables to only be sourced when the `fish-lsp` command exists.
###### :package: Default Values: fish-lsp env --show-default ```fish # $fish_lsp_enabled_handlers # 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-lsp env --create ```fish # $fish_lsp_enabled_handlers # 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: fish-lsp env --show-default --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" } ```
###### :jigsaw: Writing current values to ~/.config/fish/conf.d/fish-lsp.fish ```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 ```
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
![`# @fish-lsp-disable`](https://github.com/ndonfris/fish-lsp.dev/blob/master/public/comment.svg?raw=true)
Single document configurations can be set using fish-shell comments to disable diagnostics or formatting from applying to specific lines or sections of a file. These comments are parsed by the server when a file is opened, and can be placed anywhere in the file. If you're interested in disabling specific diagnostic messages, the [wiki](https://github.com/ndonfris/fish-lsp/wiki) includes a table of [error codes](https://github.com/ndonfris/fish-lsp/wiki/Diagnostic-Error-Codes) that should be helpful. Diagnostics are a newer feature so [PRs](https://github.com/ndonfris/fish-lsp/blob/master/docs/CONTRIBUTING.md#getting-started-rocket) are welcome to improve their support. Any diagnostic can be disabled by providing its error code to the environment variable `fish_lsp_diagnostic_disable_error_codes` (see the [template above](#environment-variables) for an example). ## Trouble Shooting If you encounter any issues with the server, the following commands may be useful to help diagnose the problem: - Show every available sub-command and flag for the `fish-lsp` ```fish fish-lsp --help-all ``` - Ensure that the `fish-lsp` command is available in your system's `$PATH` by running `which fish-lsp` or `fish-lsp info --bin`. ```fish fish-lsp info ``` - Confirm that the language server is able to startup correctly by indexing the `$fish_lsp_all_indexed_paths` directories. ```fish fish-lsp info --time-startup ``` > Note: > There is also, `fish-lsp info --time-only` which will show a less verbose summary of the startup timings. To limit either of these flags to a specific folder, use `--use-workspace ~/path/to/fish`. - Check the health of the server. ```fish fish-lsp info --check-health ``` - Check the server logs, while a server is running. ```fish set -gx fish_lsp_log_file /tmp/fish_lsp.log tail -f (fish-lsp info --log-file) # open the server somewhere else ``` - Enable [source maps](https://www.typescriptlang.org/tsconfig/#sourceMap) to debug the bundled server code. ```fish set -gx NODE_OPTIONS '--enable-source-maps --inspect' $EDITOR ~/.config/fish/config.fish ``` - Show the [tree-sitter](https://github.com/esdmr/tree-sitter-fish) parse tree for a specific file: ```fish fish-lsp info --dump-parse-tree path/to/file.fish ``` ##### Abbreviations to shorten the amount of characters typed for many of the above commands are available on the [wiki](https://github.com/ndonfris/fish-lsp/wiki/Abbreviations) ## Additional Resources - [Contributing](./docs/CONTRIBUTING.md) - documentation describing how to contribute to the fish-lsp project. - [Roadmap](./docs/ROADMAP.md) - goals for future project releases. - [Wiki](https://github.com/ndonfris/fish-lsp/wiki) - further documentation and knowledge relevant to the project - [Discussions](https://github.com/ndonfris/fish-lsp/discussions) - interact with maintainers - [Site](https://fish-lsp.dev/) - website homepage - [Client Examples](https://github.com/ndonfris/fish-lsp/wiki/Client-Configurations) - testable language client configurations - [Sources](https://github.com/ndonfris/fish-lsp/wiki/Sources) - major influences for the project ## Contributors Contributions of any kind are welcome! Special thanks to anyone who contributed to the project! :pray:
nick
nick

💻
mimikun
mimikun

💻
Jaakko Paju
Jaakko Paju

💻
Sean Perry
Sean Perry

💻
Fabio Coatti
Fabio Coatti

💻
Peter Cardenas
Peter Cardenas

💻
Peter Tri Ho
Peter Tri Ho

💻
bnwa
bnwa

💻
Branch Vincent
Branch Vincent

💻
Jaeseok Lee
Jaeseok Lee

💻
ClanEver
ClanEver

💻
Nathan DeGruchy
Nathan DeGruchy

💻
Nan Huang
Nan Huang

💻
Sola
Sola

💻
Jose Alvarez
Jose Alvarez

💻
Bernát Gábor
Bernát Gábor

💻
This project follows the [all-contributors](https://allcontributors.org) specification. ## License [MIT](https://github.com/ndonfris/fish-lsp/blob/master/LICENSE.md) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions | Version | Supported | | --------- | ------------------ | | >= 1.1.x | :white_check_mark: | | < 1.1.0 | :x: | ## Reporting a Vulnerability If you discover a security vulnerability in fish-lsp, please report it responsibly. **Do not open a public GitHub issue for security vulnerabilities.** Instead, please report vulnerabilities by emailing the maintainer directly or by using [GitHub's private vulnerability reporting](https://github.com/ndonfris/fish-lsp/security/advisories/new). ### What to include - A description of the vulnerability - Steps to reproduce the issue - The potential impact - Any suggested fixes (if applicable) ### Response timeline - **Acknowledgment**: Within 48 hours of receiving your report - **Assessment**: Within 7 days, we will assess the severity and provide an initial response - **Fix**: Critical vulnerabilities will be prioritized and patched as soon as possible ## Scope fish-lsp is a language server that runs locally and communicates with editors over stdio/TCP. The primary security considerations include: - **Code execution**: fish-lsp parses and analyzes fish shell scripts but does not execute them - **File system access**: The server reads files within your workspace to provide language features - **Dependencies**: Third-party npm packages are used and kept up to date ## Best Practices for Users - Keep fish-lsp updated to the latest version - Review workspace trust settings in your editor before opening untrusted projects - Report any unexpected behavior that could indicate a security issue ================================================ FILE: eslint.config.ts ================================================ // @ts-check import eslint from '@eslint/js'; import tseslint, { type ConfigArray } from 'typescript-eslint'; import stylistic from '@stylistic/eslint-plugin'; import globals from 'globals'; export default tseslint.config( { ignores: [ 'scripts/', 'dist/', '.bun/', 'out/', 'build/', 'lib/src/', 'lib/*.d.ts', 'release-assets/', 'vitest.config.ts', 'eslint.config.ts', ], }, { files: ['**/*.ts'], extends: [ eslint.configs.recommended, ...tseslint.configs.recommended, ], plugins: { '@stylistic': stylistic, }, languageOptions: { globals: { ...globals.node, ...globals.es2022, }, parserOptions: { projectService: true, tsconfigRootDir: __dirname, }, }, rules: { // --- Core rules --- 'no-control-regex': 'off', 'no-useless-assignment': 'off', curly: ['error', 'multi-line'], 'dot-notation': 'error', eqeqeq: 'error', 'no-console': ['warn', { allow: ['assert', 'warn', 'error'] }], 'no-constant-binary-expression': 'error', 'no-constructor-return': 'error', 'no-template-curly-in-string': 'off', 'no-fallthrough': 'off', 'no-whitespace-before-property': 'error', 'one-var-declaration-per-line': ['error', 'always'], 'no-useless-escape': 'off', 'no-extra-parens': 'off', 'no-extra-semi': 'off', // --- @stylistic rules (replaces deprecated formatting rules) --- '@stylistic/array-bracket-spacing': 'error', '@stylistic/brace-style': 'error', '@stylistic/comma-dangle': ['error', 'always-multiline'], '@stylistic/comma-spacing': 'error', '@stylistic/computed-property-spacing': 'error', '@stylistic/eol-last': 'error', '@stylistic/func-call-spacing': 'error', '@stylistic/indent': ['error', 2, { SwitchCase: 1 }], '@stylistic/keyword-spacing': 'error', '@stylistic/linebreak-style': 'error', '@stylistic/no-extra-parens': 'error', '@stylistic/no-extra-semi': 'error', '@stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }], '@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }], '@stylistic/no-tabs': 'error', '@stylistic/no-trailing-spaces': 'error', '@stylistic/nonblock-statement-body-position': ['warn', 'beside', { overrides: { while: 'below' } }], '@stylistic/object-curly-spacing': ['error', 'always'], '@stylistic/padded-blocks': ['error', 'never'], '@stylistic/quote-props': ['error', 'as-needed'], '@stylistic/space-before-blocks': 'error', '@stylistic/space-before-function-paren': ['error', { anonymous: 'never', named: 'never' }], '@stylistic/space-in-parens': 'error', '@stylistic/space-infix-ops': 'error', '@stylistic/member-delimiter-style': ['warn', { singleline: { delimiter: 'semi', requireLast: true, }, }], '@stylistic/quotes': ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], '@stylistic/semi': ['warn', 'always'], // --- @typescript-eslint rules --- '@typescript-eslint/explicit-function-return-type': ['off', { allowExpressions: true }], '@typescript-eslint/explicit-module-boundary-types': ['off', { allowArgumentsExplicitlyTypedAsAny: false }], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-require-imports': 'error', '@typescript-eslint/no-unnecessary-qualifier': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none', }], '@typescript-eslint/no-useless-constructor': 'error', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/restrict-plus-operands': 'error', '@typescript-eslint/no-unsafe-declaration-merging': 'off', }, }, { files: ['tests/**/*.ts'], rules: { '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-require-imports': 'off', 'no-console': 'off', 'no-control-regex': 'off', '@typescript-eslint/no-explicit-any': 'off', }, }, ) satisfies ConfigArray; ================================================ FILE: fish_files/exec.fish ================================================ #!/usr/bin/env fish string collect -- $argv | read --tokenize --local cmd fish --command "$cmd" 2>/dev/null # begin # string collect -- $argv | read --local --tokenize cmd # fish --command "$cmd" # end 2>/dev/null ================================================ FILE: fish_files/expand_cartesian.fish ================================================ #!/usr/bin/env fish # Example usage: # # >_ ./expand_cartisian.fish {a,b,c}/foo/{1,2,3} # 1 |a/foo/1| # 2 |a/foo/2| function expand_cartesian set idx 1 for item in (fish -c "printf %s\n $(string split0 -- (string collect -- $argv | string unescape | string join0))") printf ' %s |`%s`|\n' (string pad -c ' ' -w 3 -- "$idx") $item set idx (math $idx+1) end end expand_cartesian $argv ================================================ FILE: fish_files/get-autoloaded-filepath.fish ================================================ #!/usr/bin/env fish argparse --stop-nonopt f/function c/completion m/max=+ -- $argv or return set cmd_name (string split ' ' --max 1 --fields 1 --no-empty -- $argv) if test -z "$cmd_name" return 0 end set -ql _flag_max and set max_results $_flag_max or set max_results 100 if set -ql _flag_function path filter -f -- $fish_function_path/$cmd_name.fish 2>/dev/null | head -n $max_results return 0 end if set -ql _flag_completion path filter -f -- $fish_complete_path/$cmd_name.fish 2>/dev/null | head -n $max_results return 0 end ================================================ FILE: fish_files/get-command-options.fish ================================================ #!/usr/bin/env fish function backup_input set -a -l _fish_lsp_file_cmps (fish -c "complete --do-complete '$argv -' | uniq") (fish -c "complete --do-complete '$argv ' | uniq") for _fish_lsp_cmp in $_fish_lsp_file_cmps echo "$_fish_lsp_cmp" end return 0; and exit end # file is just used to get command options # not used for tokens other than one needing a commandline completion if test (count $argv) -ge 2 fish -c "complete --do-complete '$argv' | uniq" else backup_input $argv end ================================================ FILE: fish_files/get-completion.fish ================================================ #!/usr/bin/env fish ## # File takes two arguments: # $argv[1] = '1' | '2' | '3' # $argv[2] = string to be completed from the shell # ## function build_cmd --argument-names input set --local input_arr (string split --right --max 1 ' ' -- "$input") #switch "$input_arr[2]" ##case '-*' ##printf "complete --escape --do-complete '$input' | uniq | string match --regex --entire '^\-'" ##case '' ##string match -req '^\s?\$' -- "$input_arr[1]"; ##printf "complete --escape --do-complete '$input' | uniq "; ##or printf "complete --escape --do-complete '$input -' | uniq | string match --regex --entire '^\-' && complete --escape --do-complete '$input ' | uniq"; #case '*' #end printf "complete --escape --do-complete '$argv' | uniq" end # taken from my fish_config function get-completions set --local cmd (build_cmd "$argv") eval $cmd end function get-subcommand-completions set --local cmd (printf "complete --escape --do-complete '$argv ' | uniq") eval $cmd end function get-variable-completions if contains $argv (set -n) set --show $argv end end switch "$argv[1]" case '1' get-completions "$argv[2..]" case '2' get-subcommand-completions "$argv[2..]" case '3' get-variable-completions "$argv[2..]" case '*' get-completions "$argv" end ================================================ FILE: fish_files/get-dependency.fish ================================================ #!/usr/local/bin/fish set -l filepath (functions --all -D "$argv" 2>> /dev/null) switch $filepath case 'n/a' echo "" return 0 case \* echo "$filepath" return 0 end ================================================ FILE: fish_files/get-docs.fish ================================================ #!/usr/bin/env fish # ┌───────┐ # │ utils │ # └───────┘ function __handle_builtin -d 'Retrieve documentation for a fish builtin' man $argv 2>/dev/null | sed -r 's/^ {7}/ /' | col -bx # Alt Approach: # >_ `__fish_print_help $argv 2>/dev/null | command cat` end function __handle_function -d 'Retrieve documentation for a fish function' set output (functions -av $argv 2>/dev/null | col -bx) if test -n "$output" printf %s\n $output return 0 else echo "ERROR(builtin): $argv doesn't have help documentation" >&2 return 1 end end function __handle_command -d 'Retrieve documentation for a system command' set output (man -a $argv 2>/dev/null | sed -r 's/^ {7}/ /' | col -bx) if test -n "$output" printf %s\n $output return 0 else echo "ERROR(man $argv): $argv doesn't have man page" >&2 return 1 end end # git worktree --help -> git worktree # git commit -m "msg" -> git commit # git --help -> git function validate_args -d 'Validate input by stopping on first non-option argument' for arg in $argv switch $arg case '-*' break case '*' printf "%s\n" $arg end end end # ┌────────────────────┐ # │ special processing │ # └────────────────────┘ # argparse --strict-longopts --move-unknown --unknown-arguments=none --stop-nonopt \ # 'function=&' 'builtin=&' 'command=&' 'use-help=&' 'h/help=&' -- $argv &>/dev/null # or argparse --ignore-unknown --stop-nonopt \ 'function=&' \ 'builtin=&' \ 'command=&' \ 'use-help' \ 'with-col' \ 'h/help=&' \ -- $argv &>/dev/null or return 0 # ┌──────────────┐ # │ help message │ # └──────────────┘ if set -ql _flag_h or set -ql _flag_help echo "Usage: get-docs.fish [OPTIONS] COMMAND Retrieve documentation for fish builtins, functions, or commands. Options: --function Retrieve documentation for a fish function --builtin Retrieve documentation for a fish builtin --command Retrieve documentation for a system command --use-help Use help documentation if available --with-col Make sure pager is not used (pipes output through 'col -bx') -h, --help Show this help message and exit Examples: >_ get-docs.fish cd >_ get-docs.fish complete >_ get-docs.fish --function my_custom_function >_ get-docs.fish --builtin set >_ get-docs.fish --use-help --with-col set " return 0 end # ┌────────────┐ # │ core logic │ # └────────────┘ if set -ql _flag_use_help set cmd $argv --help if set -ql _flag_with_col set -a cmd \| col -bx end eval $cmd 2>/dev/null return $status end if set -ql _flag_builtin || builtin -q $argv[1] 2>/dev/null __handle_builtin (string join '-' --no-empty -- (validate_args $argv)) return $status end if set -ql _flag_function || functions -aq $argv[1] 2>/dev/null __handle_function $argv return $status end if set -ql _flag_command || command -aq $argv[1] 2>/dev/null __handle_command (string join '-' --no-empty -- (validate_args $argv)) return $status end echo "ERROR: '$argv' is not a valid fish builtin, command or function" >&2 return 1 ================================================ FILE: fish_files/get-documentation.fish ================================================ #!/usr/bin/env fish ## commands like mkdir or touch should reach this point function _flsp_get_command_without_manpage -d 'fallback for a command passed in without a manpage' set -l completions_docs (complete --do-complete="$argv -") if test -n "$completions_docs" echo -e "\t$argv Completions" echo $completions_docs[..10] else if test -n "$($argv --help 2>> /dev/null)" echo -e "\t$argv --help output" $argv --help 2>> /dev/null else echo '' end end function _flsp_get_manpage -d 'for a command with a manpage' man $argv | command col end set -l type_result (type -at "$argv[1]" 2> /dev/null) switch "$type_result" case "function" if type -f -q $argv 2>/dev/null _flsp_get_manpage $argv else functions --all $argv | tr -d '\b' end case "builtin" __fish_print_help $argv 2>/dev/null | command cat return 0 case "file" set -l bad_manpage ( man -a $argv 2> /dev/null ) if test -z "$bad_manpage" echo '' return else if string match -rq "No manual entry for $argv" -- $bad_manpage _flsp_get_command_without_manpage $argv else _flsp_get_manpage $argv end case \* set -l bad_manpage ( man -a $argv 2> /dev/null ) if test -z "$bad_manpage" echo '' return 0 else _flsp_get_manpage $argv return 0 end end ================================================ FILE: fish_files/get-fish-autoloaded-paths.fish ================================================ #!/usr/bin/env fish echo -e "__fish_bin_dir\t$(string join ':' -- $__fish_bin_dir)" echo -e "__fish_config_dir\t$(string join ':' -- $__fish_config_dir)" echo -e "__fish_data_dir\t$(string join ':' -- $__fish_data_dir)" echo -e "__fish_help_dir\t$(string join ':' -- $__fish_help_dir)" # docs unclear: https://fishshell.com/docs/current/language.html#syntax-function-autoloading # includes __fish_sysconfdir but __fish_sysconf_dir is defined on local system echo -e "__fish_sysconfdir\t$(string join ':' -- $__fish_sysconfdir)" echo -e "__fish_sysconf_dir\t$(string join ':' -- $__fish_sysconf_dir)" echo -e "__fish_user_data_dir\t$(string join ':' -- $__fish_user_data_dir)" echo -e "__fish_added_user_paths\t$(string join ':' -- $__fish_added_user_paths)" echo -e "__fish_vendor_completionsdirs\t$(string join ':' -- $__fish_vendor_completionsdirs)" echo -e "__fish_vendor_confdirs\t$(string join ':' -- $__fish_vendor_confdirs)" echo -e "__fish_vendor_functionsdirs\t$(string join ':' -- $__fish_vendor_functionsdirs)" echo -e "fish_function_path\t$(string join ':' -- $fish_function_path)" echo -e "fish_complete_path\t$(string join ':' -- $fish_complete_path)" echo -e "fish_user_paths\t$(string join ':' -- $fish_user_paths)" ================================================ FILE: fish_files/get-type-verbose.fish ================================================ #!/usr/bin/env fish # takes a single argument and returns a string/token # without throwing an error # possible return values are: # 'builtin' # 'variable' # 'abbr' # 'command' # 'function' # 'alias' ? # meant to be used on a token. Below outlines the inclusion and exclusion of behavior # expected by this shell script. # includes: some_builtin | some_function | some_variable | some_abbr | some_command # excludes: options | flags | subcommands | $variable | $$variables function get_type_verbose --argument-names str # EDITING THIS SCRIPT? # ORDER OF OPERATIONS MATTERS! if builtin --query -- "$str" echo "builitn" else if abbr -q -- "$str" echo 'abbr' else if functions --all --query -- "$str" # could be alias or function echo 'function' else if command -q -- "$str" echo 'command' else if set --query -- "$str" echo 'variable' else echo '' end end function get_first_token string match -req '^(\w+)-(\w+)$' -- "$argv" and string split -m 1 -f 1 '-' -- "$argv" or echo "$argv[1]" end set -l first "$(get_first_token $argv)" get_type_verbose $first ================================================ FILE: fish_files/get-type.fish ================================================ #!/usr/bin/env fish function get_type --argument-names str set -l type_result (type -t "$str" 2> /dev/null) switch "$type_result" case "function" if type -f -q $str 2>/dev/null || contains -- $str export echo 'command' else echo 'file' end case "builtin" echo 'command' case "file" echo 'command' case \* echo '' end end # command - shown using man # file - shown using functions query set -l first (string split -f 1 '-' -- "$argv") set -l normal_output (get_type "$argv") set -l fallback_output (get_type "$first") if test -n "$normal_output" echo "$normal_output" else if test -n "$fallback_output" echo "$fallback_output" else echo '' end ================================================ FILE: man/fish-lsp.1 ================================================ .TH "FISH\-LSP" "1" "April 2026" "1.1.4-pre.0" "fish-lsp" .SH "NAME" \fBfish-lsp\fR \- A language server for the fish shell .SH SYNOPSIS .P \fBfish\-lsp [OPTIONS]\fP .br \fBfish\-lsp [SUBCOMMAND] [OPTIONS]\fP .SH DESCRIPTION .P \fBfish\-lsp\fP is a language server for the fish shell\. It provides IDE\-like features for fish shell scripts, such as syntax checking, linting, and auto\-completion\. .P It requires a language client that supports the Language Server Protocol (LSP)\. .P Some common language clients include: the builtin API for \fBnvim\fP (v0\.9+), lsp\-mode for \fBemacs\fP, or the fish\-lsp extension for \fBVSCode\fP\|\. .P Documentation below shows usage of the \fBfish\-lsp\fP command, including its subcommands and options\. .SH OPTIONS .P \fB\-v\fP or \fB\-\-version\fP Show version information and exit\. .br \fB\-h\fP or \fB\-\-help\fP Show help message and exit\. .br \fB\-\-help\-all\fP Show all the help information .br \fB\-\-help\-short\fP Show shortened help message .br \fB\-\-help\-man\fP Show manpage output .SH SUBCOMMANDS .SS \fBstart\fP .P Start the language server\. .P \fB\-\-enable\fP enable the language server features .br \fB\-\-disable\fP disable the language server features .br \fB\-\-dump\fP dump the json output of the language server features enabled after startup .br \fB\-\-stdio\fP use stdin/stdout for communication (default) .br \fB\-\-node\-ipc\fP use node IPC for communication .br \fB\-\-socket \fP use TCP socket for communication .br \fB\-\-memory\-limit \fP set memory usage limit in MB .br \fB\-\-max\-files \fP override the maximum number of files to analyze .br \fB\-\-web\fP start server in web mode used for https://fish-lsp.dev/playground .SS \fBenv\fP .P show the environment variables available to the lsp .P \fB\-c\fP or \fB\-\-create\fP create the environment variable .br \fB\-s\fP or \fB\-\-show\fP show the environment variables .br \fB\-\-show\-default\fP show the default values for fish\-lsp env variables .br \fB\-\-only \fP only include the specified environment variables in the output .br \fB\-\-no\-global\fP don't use global scope when generating environment variables .br \fB\-\-no\-local\fP don't use local scope when generating environment variables .br \fB\-\-no\-export\fP don't use export flag when generating environment variables .br \fB\-\-no\-comments\fP skip outputting comments .br \fB\-\-confd\fP output for redirecting to conf\.d/fish\-lsp\.fish .br \fB\-\-json\fP output \fBfish_lsp_*\fP initialization variables as JSON object (for vscode \fBsettings\.json\fP) .SS \fBinfo\fP .P show the build info of fish\-lsp .P \fB\-\-bin\fP show the path of the fish\-lsp executable .br \fB\-\-path\fP show the path of the entire fish\-lsp installation .br \fB\-\-build\-time\fP show the path of the entire fish\-lsp repo .br \fB\-\-build\-type\fP show the build type of the command .br \fB\-v\fP or \fB\-\-version\fP show the lsp version .br \fB\-\-lsp\-version\fP show the vscode\-languageserver version .br \fB\-\-capabilities\fP show the lsp capabilities .br \fB\-\-man\-file\fP show the man file path .br \fB\-\-log\-file\fP show the log file path .br \fB\-\-show\fP show the man/log file contents (needs to be paired with \fB\-\-log\-file\fP or \fB\-\-man\-file\fP) .br \fB\-\-extra\fP show debugging server info (capabilities, paths, version, etc\.) .br \fB\-\-verbose\fP show debugging server info (capabilities, paths, version, etc\.) .br \fB\-\-check\-health\fP run diagnostics and report health status .br \fB\-\-health\-check\fP run diagnostics and report health status .br \fB\-\-time\-startup\fP time the startup of the fish\-lsp executable .br \fB\-\-time\-only\fP show brief summary of the startup timing .br \fB\-\-use\-workspace \fP use the workspace at the specified directory path when \fBfish\-lsp info \-\-time\-startup\fP is used .br \fB\-\-no\-warning\fP disable message in the \fBfish\-lsp info \-\-time\-startup\fP output .br \fB\-\-show\-files\fP show the files that were indexed during startup when \fBfish\-lsp info \-\-time\-startup\fP is used .br \fB\-\-dump\-symbol\-tree \fP show the fish\-lsp definition symbol tree for the specified file .br \fB\-\-dump\-parse\-tree \fP show the tree\-sitter AST for the specified file .br \fB\-\-dump\-semantic\-tokens \fP show the semantic\-tokens for the specified file .br \fB\-\-no\-color\fP disable color output from \fB\-\-dump\-*\fP output .br \fB\-\-no\-icons\fP disable icon usage in output from \fBfish\-lsp info \-\-dump\-symbol\-tree\fP .br \fB\-\-source\-maps\fP show the source\-maps .br \fB\-\-check\fP used in combination with \fB\-\-source\-maps\fP, verifies source\-maps are working by throwing an error .br \fB\-\-status\fP used in combination with \fB\-\-source\-maps\fP, shows status of source\-maps loading .SS \fBurl\fP .P show a helpful url related to the fish\-lsp .P \fB\-\-repo\fP or \fB\-\-git\fP show the github repo .br \fB\-\-npm\fP show the npm package url .br \fB\-\-homepage\fP show the homepage .br \fB\-\-contributions\fP show the contributions url .br \fB\-\-wiki\fP show the github wiki .br \fB\-\-issues\fP or \fB\-\-report\fP show the issues page .br \fB\-\-discussions\fP show the discussions page .br \fB\-\-clients\-repo\fP show the clients configuration repo .br \fB\-\-sources\fP show a list of helpful sources .SS \fBcomplete\fP .P Provide completions for the \fBfish\-lsp\fP .P \fB\-\-names\fP show the feature names of the completions .br \fB\-\-toggles\fP show the feature names of the completions .br \fB\-\-fish\fP show fish script .br \fB\-\-features\fP show features .br \fB\-\-env\-variables\fP show env variable completions .br \fB\-\-env\-variable\-names\fP show env variable names .br \fB\-\-names\-with\-summary\fP show the names with the summary for the completions .br \fB\-\-abbreviations\fP show the 'fish\-lsp' subcommand abbreviations .SH EXAMPLES .RS 1 .IP \(bu 2 Start the \fBfish\-lsp\fP language server, with the default configuration: .RS 2 .nf >_ fish\-lsp start .fi .RE .IP \(bu 2 Generate the completions for the \fBfish\-lsp\fP language server binary: .RS 2 .nf >_ fish\-lsp complete > ~/\.config/fish/completions/fish\-lsp\.fish .fi .RE .IP \(bu 2 Debug the \fBfish\-lsp\fP language server by dumping the enabled features after startup: .RS 2 .nf >_ fish\-lsp start \-\-dump .fi .RE .IP \(bu 2 Show information about the \fBfish\-lsp\fP language server: .RS 2 .nf >_ fish\-lsp info .fi .RE .IP \(bu 2 Show all the available information about the \fBfish\-lsp\fP language server: .RS 2 .nf >_ fish\-lsp info \-\-verbose .fi .RE .IP \(bu 2 Show startup timing information for the \fBfish\-lsp\fP language server: .RS 2 .nf >_ fish\-lsp info \-\-time\-startup .fi .RE .IP \(bu 2 Show startup timing information for the \fBfish\-lsp\fP language server for a specific workspace: .RS 2 .nf >_ fish\-lsp info \-\-time\-startup \-\-use\-workspace ~/\.config/fish \-\-no\-warning .fi .RE .IP \(bu 2 Preform a health check on the \fBfish\-lsp\fP language server: .RS 2 .nf >_ fish\-lsp info \-\-check\-health .fi .RE .IP \(bu 2 Show the definition symbol tree for a specific file: .RS 2 .nf >_ fish\-lsp info \-\-dump\-symbol\-tree ~/\.config/fish/config\.fish .fi .RE .IP \(bu 2 Show the semantic tokens for a specific file (read from \fBstdin\fP): .RS 2 .nf >_ cat $__fish_data_dir/config\.fish | fish\-lsp info \-\-dump\-semantic\-tokens .fi .RE .IP \(bu 2 Show the environment variables available to the \fBfish\-lsp\fP language server: .RS 2 .nf >_ fish\-lsp env \-\-show .fi .RE .IP \(bu 2 Show the default values for specific environment variables used by the \fBfish\-lsp\fP language server: .RS 2 .nf >_ fish\-lsp env \-\-show\-default \-\-only fish_lsp_all_indexed_paths,fish_lsp_max_background_files \-\-no\-comments .fi .RE .IP \(bu 2 Get sources related to the \fBfish\-lsp\fP language server's development: .RS 2 .nf >_ fish\-lsp url \-\-sources .fi .RE .RE .SH SEE ALSO .RS 1 .IP \(bu 2 \fBwebsite:\fR \fIhttps://fish-lsp.dev/\fR .IP \(bu 2 \fBrepo:\fR \fIhttps://github.com/ndonfris/fish-lsp\fR .IP \(bu 2 \fBfish website:\fR \fIhttps://fishshell.com/\fR .RE .SH AUTHOR .RS 1 .IP \(bu 2 Nick Donfris .RE ================================================ FILE: package.json ================================================ { "$schema": "https://json.schemastore.org/package", "author": "ndonfris", "license": "MIT", "name": "fish-lsp", "version": "1.1.4-pre.0", "description": "LSP implementation for fish/fish-shell", "keywords": [ "lsp", "fish", "fish-shell", "language-server-protocol", "language-server" ], "type": "commonjs", "homepage": "https://fish-lsp.dev", "repository": { "type": "git", "url": "git+https://github.com/ndonfris/fish-lsp.git" }, "bugs": { "url": "https://github.com/ndonfris/fish-lsp/issues" }, "funding": { "url": "https://github.com/ndonfris", "type": "github" }, "engines": { "node": ">=20.0.0" }, "files": [ "dist/fish-lsp", "dist/fish-lsp.d.ts", "package.json", "man/fish-lsp.1", "README.md", "LICENSE.md" ], "main": "./dist/fish-lsp", "typings": "./dist/fish-lsp.d.ts", "browser": "./dist/fish-lsp", "exports": { ".": { "types": "./dist/fish-lsp.d.ts", "import": "./dist/fish-lsp", "require": "./dist/fish-lsp" }, "./server": { "types": "./dist/fish-lsp.d.ts", "import": "./dist/fish-lsp", "require": "./dist/fish-lsp" }, "./web": { "types": "./dist/fish-lsp.d.ts", "import": "./dist/fish-lsp", "require": "./dist/fish-lsp" } }, "bin": { "fish-lsp": "dist/fish-lsp" }, "man": "man/fish-lsp.1", "scripts": { "prepare": "husky", "prepack:pack": "run-s --continue-on-error clean:build lint:check update-changelog generate:man generate:commands build:npm build:types sh:build-completions", "package": "yarn pack --filename fish-lsp.tgz", "build": "run-s -sn build:all sh:relink sh:build-completions", "build:watch": "run-s watch", "build:npm": "tsx scripts/esbuild/index.ts --npm", "build:npm:nosourcemaps": "tsx scripts/esbuild/index.ts --npm --sourcemaps=none", "build:types": "tsx scripts/esbuild/index.ts --types", "build:all": "tsx scripts/esbuild/index.ts --all", "dev": "tsx scripts/esbuild/index.ts", "watch": "tsx scripts/esbuild/index.ts --watch-all", "sh:build-completions": "fish ./scripts/build-completions.fish", "sh:build-time": "node ./scripts/build-time", "sh:relink": "fish ./scripts/relink-locally.fish", "sh:build-assets": "fish ./scripts/build-assets.fish", "sh:dev:complete:install": "fish ./scripts/dev-complete.fish --install", "sh:dev:complete:uninstall": "fish ./scripts/dev-complete.fish --uninstall", "sh:workspace-cli": "tsx ./scripts/workspace-cli.ts", "rm": "rimraf", "clean": "rimraf out dist lib man bin node_modules *.tgz .tsbuildinfo coverage .bun", "clean:all": "rimraf out lib dist bin .tsbuildinfo node_modules tree-sitter-fish.wasm logs.txt coverage .bun", "clean:build": "rimraf out lib dist bin .tsbuildinfo", "clean:packs": "rimraf *.tgz .tsbuildinfo", "clean:dev-completions": "fish ./scripts/dev-complete.fish --uninstall", "test": "env -i HOME=$HOME PATH=$PATH NODE_ENV=test USER=test_user vitest", "test:run": "env -i HOME=$HOME PATH=$PATH NODE_ENV=test USER=test_user vitest --run", "test:coverage": "env -i HOME=$HOME PATH=$PATH NODE_ENV=test USER=test_user vitest --coverage", "test:coverage:ui": "env -i HOME=$HOME PATH=$PATH NODE_ENV=test USER=test_user vitest --ui --open --coverage", "test:coverage:run": "env -i HOME=$HOME PATH=$PATH NODE_ENV=test USER=test_user vitest --coverage --run", "publish-nightly": "fish ./scripts/publish-nightly.fish", "refactor": "knip", "lint:check": "eslint .", "lint:fix": "eslint . --fix", "lint:check-fix": "eslint . --fix-dry-run", "update:prerelease": "run-s lint:fix update-changelog generate:man update-codeblocks-in-docs", "update-codeblocks-in-docs": "tsx scripts/update-codeblocks-in-docs.ts", "update-changelog": "fish scripts/update-changelog.fish", "util:update-changelog": "conventional-changelog -i docs/CHANGELOG.md --same-file", "util:update-changelog:dry": "conventional-changelog -i docs/CHANGELOG.md --stdout", "util:update-changelog:dry:diff": "conventional-changelog -i docs/CHANGELOG.md --stdout | diff --color=always --unified docs/CHANGELOG.md -", "all-contributors": "npx -s -y all-contributors-cli -c .all-contributorsrc", "generate:commands": "tsx ./scripts/fish-commands-scrapper.ts --write-to-snippets || true", "generate:commands:check": "tsx ./scripts/fish-commands-scrapper.ts", "generate:snippets": "tsx ./scripts/fish-commands-scrapper.ts", "create:man:dir": "mkdir -p ./man", "generate:man": "run-s create:man:dir generate:man:actual", "generate:man:cat": "npx marked-man --date \"$(date)\" --manual fish-lsp --section 1 -i ./docs/MAN_FILE.md -o ./man/fish-lsp.1 2>/dev/null", "generate:man:actual": "yarn run --silent generate:man:cat > ./man/fish-lsp.1", "generate:man:diff": "yarn run --silent generate:man:cat | diff --color=always --unified ./man/fish-lsp.1 - && echo 'NO CHANGES TO man/fish-lsp.1' || echo 'CHANGES IN man/fish-lsp.1'", "generate:man:cp": "cp ./man/fish-lsp.1 ~/.local/share/man/man1/fish-lsp.1", "generate:man:write-global": "run-s generate:man generate:man:cp" }, "lint-staged": { "**/*.ts": [ "eslint --fix" ] }, "contributes": { "commands": [ { "command": "fish-lsp.executeRange", "title": "execute the range" }, { "command": "fish-lsp.executeLine", "title": "execute the line" }, { "command": "fish-lsp.executeBuffer", "title": "execute the buffer" }, { "command": "fish-lsp.execute", "title": "execute the buffer" }, { "command": "fish-lsp.createTheme", "title": "create a new theme" }, { "command": "fish-lsp.showStatusDocs", "title": "show the status documentation" }, { "command": "fish-lsp.showWorkspaceMessage", "title": "show the workspace message" }, { "command": "fish-lsp.updateWorkspace", "title": "update the workspace" }, { "command": "fish-lsp.fixAll", "title": "execute all quick-fixes in file" }, { "command": "fish-lsp.toggleSingleWorkspaceSupport", "title": "enable/disable single workspace support" }, { "command": "fish-lsp.generateEnvVariables", "title": "output the $fish_lsp_* environment variables" }, { "command": "fish-lsp.showReferences", "title": "show references" }, { "command": "fish-lsp.showInfo", "title": "show server info" } ] }, "dependencies": { "@esdmr/tree-sitter-fish": "^3.7.0", "chalk": "^5.6.2", "commander": "^12.1.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.4", "husky": "^9.1.7", "memfs": "4.38.1", "npm-run-all": "^4.1.5", "source-map-support": "^0.5.21", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0", "web-tree-sitter": "^0.23.0", "zod": "^3.25.76" }, "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^20.5.0", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^4.4.1", "@tsconfig/node-ts": "^23.6.4", "@tsconfig/node22": "^22.0.5", "@types/chokidar": "^2.1.7", "@types/eslint": "^9.6.1", "@types/fs-extra": "^11.0.4", "@types/jsdom": "^21.1.7", "@types/node": "^24.12.2", "@types/node-fetch": "^2.6.13", "@vitest/coverage-v8": "3.2.4", "@vitest/ui": "^3.2.4", "chokidar": "^4.0.3", "conventional-changelog": "^7.2.0", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.28.0", "esbuild-plugin-polyfill-node": "^0.3.0", "esbuild-plugins-node-modules-polyfill": "^1.8.1", "eslint": "^9.39.4", "fast-check": "^4.7.0", "globals": "^16.5.0", "hono": "^4.12.14", "jsdom": "^26.1.0", "knip": "^5.88.1", "lint-staged": "^15.5.2", "marked-man": "^1.3.6", "node-fetch": "^3.3.2", "rimraf": "^6.1.3", "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.59.0", "vite": "^7.3.2", "vite-plugin-wasm": "^3.6.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" }, "resolutions": { "glob": "^12.0.0", "minimatch": "^10.2.0" } } ================================================ FILE: renovate.json ================================================ { "extends": [ "config:base", ":prHourlyLimit4", ":semanticCommitTypeAll(chore)" ], "schedule": [ "after 10am every monday" ], "meteor": { "enabled": false }, "rangeStrategy": "bump", "npm": { "commitMessageTopic": "{{prettyDepType}} {{depName}}" }, "packageRules": [ { "matchPackageNames": [ "node" ], "enabled": false }, { "groupName": "all non-major dependencies", "groupSlug": "all-minor-patch", "matchFiles": ["package.json"], "matchUpdateTypes": [ "minor", "patch" ], "lockFileMaintenance": { "enabled": true, "extends": [ "schedule:weekly" ] } } ] } ================================================ FILE: scripts/build-assets.fish ================================================ #!/usr/bin/env fish # Automation script to build all assets for releasing fish-lsp. The files # outputted by this script are intended to be located in the release-assets/ # folder. # # These files are included in the release-assets/ folder: # - fish-lsp.standalone (standalone binary -- bundled dependencies into a single executable, npm package will be smaller) # - fish-lsp.standalone.extra-assets.tar (standalone w/ sourcemaps, manpage, completions, and TypeScript declarations) # - fish-lsp.tgz (npm packaged tarball) # - fish-lsp.no-sourcemaps.tgz (npm packaged tarball, no sourcemaps) # - fish-lsp.1 (man page) # - fish-lsp.fish (shell completions) # # Usage: # # Build assets, and upload them to a GitHub release # >_ yarn sh:build-assets [--clean] [--fresh-install] # >_ gh release upload ./release-assets/* # # >_ fish ./scripts/build-assets.fish # Build assets without using yarn # source ./scripts/fish/continue-or-exit.fish source ./scripts/fish/pretty-print.fish argparse clean fresh-install h/help -- "$argv" or fail 'Failed to parse arguments.' if set -q _flag_help echo 'Usage:' echo ' yarn sh:build-assets [--clean] [--fresh-install] [--help]' echo ' fish ./scripts/build-assets.fish [--clean] [--fresh-install] [--help]' echo '' echo 'Synopsis:' echo ' Script to build all assets for releasing fish-lsp. Assets are outputted' echo ' in the ./release-assets/ directory.' echo '' echo 'Options:' echo ' --clean Remove the release-assets/ directory and exit.' echo ' --fresh-install Install dependencies from scratch before building.' echo ' -h, --help Show this help message and exit.' exit 0 end if set -q _flag_clean not test -d release-assets && and log_warning '' '[WARNING]' 'release-assets/ directory does not exist. Nothing to clean.' and exit 0 rm -rf release-assets and success ' Cleaned up release-assets/ directory. ' exit 0 end if test -d release-assets log_warning '' '[WARNING]' 'Directory release-assets/ already exists and will be removed.' rm -rf release-assets end if not test -d release-assets log_info '' '[INFO]' 'Creating release-assets/ directory...' mkdir -p release-assets or fail 'Failed to create release-assets/ directory.' log_info '' '[INFO]' 'Directory release-assets/ created successfully!' end log_info '' '[INFO]' 'Building project...' yarn install &>/dev/null if set -q _flag_fresh_install yarn run clean:packs &>/dev/null and log_info '' '[INFO]' 'Dependencies installed successfully!' or fail 'Failed to install dependencies.' end yarn build &>/dev/null log_info '' '[INFO]' 'Project built successfully!' log_info '' '[INFO]' 'Creating npm package tarball...' yarn pack --filename release-assets/fish-lsp.tgz --silent or fail 'Failed to create npm package tarball.' log_info '' '[INFO]' 'Creating npm package tarball (no sourcemaps)...' yarn build:npm:nosourcemaps &>/dev/null or fail 'Failed to build npm package without sourcemaps.' yarn pack --filename release-assets/fish-lsp.no-sourcemaps.tgz --silent or fail 'Failed to create npm package tarball (no sourcemaps).' log_info '' '[INFO]' 'Creating standalone binary...' yarn build:all &>/dev/null log_info '' '[INFO]' 'Creating release-assets extra files...' yarn run -s generate:man &>/dev/null && command cp man/fish-lsp.1 release-assets/fish-lsp.1 ./dist/fish-lsp complete >release-assets/fish-lsp.fish log_info '' '[INFO]' 'Creating tarball for extra files...' tar -cf release-assets/fish-lsp.standalone.with-all-assets.tar bin man dist/fish-lsp.d.ts &>/dev/null or log_warning '' '[WARNING]' 'failed to create `release-assets/fish-lsp.standalone.with-all-assets.tar` archive.' log_info '' '[INFO]' 'Copying standalone binary to release-assets/ directory...' command cp bin/fish-lsp release-assets/fish-lsp.standalone or log_warning '' '[WARNING]' 'failed to copy `bin/fish-lsp` to `release-assets/fish-lsp.standalone`!' print_separator echo '' set_color --bold green yarn exec -- npx -s -y tree-cli --base ./release-assets/ or true set_color normal print_separator success " All assets built successfully! 📦 " ================================================ FILE: scripts/build-completions.fish ================================================ #!/usr/bin/env fish source ./scripts/fish/pretty-print.fish # The below if statement is only included because of possible CI/CD edge-cases. # For almost all users, this should not do anything. if not test -d $HOME/.config/fish/completions mkdir -p $HOME/.config/fish/completions if not contains -- $HOME/.config/fish/completions $fish_complete_path set --append --global --export fish_complete_path $HOME/.config/fish/completions $fish_complete_path end end argparse h/help s/source -- $argv or return if set -q _flag_help echo 'NAME:' echo ' build-completions.fish' echo '' echo 'DESCRIPTION:' echo ' Generate completions for fish-lsp.' echo '' echo 'OPTIONS:' echo -e ' -s,--source\terase shell\'s completions and source current fish-lsp completions' echo -e ' -h,--help\tshow this message' echo '' echo 'EXAMPLES:' echo -e ' >_ ./build-completions.fish ' echo -e ' no args will overwrite the $fish_complete_path[1]/fish-lsp.fish file with the current completions' echo -e '' echo -e ' >_ ./build-completions.fish -s' echo -e ' erase the current completions and source the new completions from the current fish-lsp' echo -e ' this will not overwrite the $fish_complete_path[1]/fish-lsp.fish file' return 0 end if set -q _flag_source complete -c fish-lsp -e complete -e fish-lsp ./dist/fish-lsp complete | source # ./bin/fish-lsp complete | source and print_success "Generated completions for fish-lsp in $BLUE'$fish_complete_path[1]/fish-lsp.fish'" or print_failure "Failed to generate completions for fish-lsp in $BLUE'$fish_complete_path[1]/fish-lsp.fish'" return 0 end # ./bin/fish-lsp complete > $fish_complete_path[1]/fish-lsp.fish ./dist/fish-lsp complete >$fish_complete_path[1]/fish-lsp.fish and print_success "Generated completions for fish-lsp in $BLUE'$fish_complete_path[1]/fish-lsp.fish'" or print_failure "Failed to generate completions for fish-lsp in $BLUE'$fish_complete_path[1]/fish-lsp.fish'" ================================================ FILE: scripts/build-time ================================================ #!/usr/bin/env node const fs = require('fs'); const path = require('path'); // Parse flags const args = process.argv.slice(1).filter(arg => arg.startsWith('-')); const flags = { quiet: args.includes('-q') || args.includes('--quiet'), verbose: args.includes('-v') || args.includes('--verbose'), help: args.includes('-h') || args.includes('--help'), noColor: args.includes('-n') || args.includes('--no-color'), color: args.includes('--color'), complete: args.includes('-c') || args.includes('--complete'), forceSuccess: args.includes('-f') || args.includes('--force-success') }; // Setup colors const colors = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', italic: '\x1b[3m', underline: '\x1b[4m', inverse: '\x1b[7m', black: '\x1b[30m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m', bgBlack: '\x1b[40m', bgRed: '\x1b[41m', bgGreen: '\x1b[42m', bgYellow: '\x1b[43m', bgBlue: '\x1b[44m', bgMagenta: '\x1b[45m', bgCyan: '\x1b[46m', bgWhite: '\x1b[47m', }; Object.keys(colors).forEach(color => { String.prototype[color] = flags.noColor || color === 'reset' ? function () {return this.toString();} : function () {return `${colors[color]}${this}${colors.reset}`;}; }); // Handle conflicting flags if (flags.color && flags.noColor) flags.help = true; if (flags.help) { console.log(`Usage:`.reset().bold(), `yarn sh:build-time [OPTIONS] ./scripts/build-time [OPTIONS] ${'Description:'.reset().bold()} This script creates a file in the 'out' directory with the current date and time for the most recent \`fish-lsp\` package's build. ${'Options:'.reset().bold()} -h, --help Show this help message -q, --quiet Suppress output -v, --verbose Enable verbose output --color Enable colored output -n, --no-color Disable colored output -f, --force-success Force success exit code `); process.exit(0); } if (flags.complete) { const file = path.resolve(__filename); const logStr = ` complete --path ${file} -f complete --path ${file} -s c -l complete -d "generate shell completions" complete --path ${file} -l color -d "Enable colored output" complete --path ${file} -s n -l no-color -d "Disable colored output" complete --path ${file} -s h -l help -d "show help message" complete --path ${file} -s q -l quiet -d "suppress output" complete --path ${file} -s v -l verbose -d "enable verbose output" complete --path ${file} -s f -l force-success -d "force success exit" # yarn sh:build-time complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -f complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -s c -l complete -d "generate shell completions" complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -l color -d "Enable colored output" complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -s n -l no-color -d "Disable colored output" complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -s h -l help -d "show help message" complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -s q -l quiet -d "suppress output" complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -s v -l verbose -d "enable verbose output" complete -c yarn -n '__fish_seen_subcommand_from sh:build-time' -s f -l force-success -d "force success exit" ` process.stdout.write(logStr) process.exit(0) } const log = (...args) => !flags.quiet && console.log(...args); const error = (...args) => !flags.quiet && console.error(...args); const verbose = (...args) => flags.verbose && console.log(...args); const exit = (code = 0) => process.exit(flags.forceSuccess ? 0 : code); try { // Use SOURCE_DATE_EPOCH for reproducible builds (standard for Nix/nixpkgs) // Falls back to current time if not set const now = process.env.SOURCE_DATE_EPOCH ? new Date(parseInt(process.env.SOURCE_DATE_EPOCH) * 1000) : new Date(); const timestamp = now.toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'medium'}); const buildTimeData = { date: now.toDateString(), timestamp, isoTimestamp: now.toISOString(), unix: Math.floor(now.getTime() / 1000), version: process.env.npm_package_version || 'unknown', nodeVersion: process.version, reproducible: !!process.env.SOURCE_DATE_EPOCH }; const scriptDir = path.dirname(path.resolve(__filename)); const outDir = scriptDir.endsWith('scripts') ? path.join(path.dirname(scriptDir), 'out') : path.join(scriptDir, 'out'); const jsonFilePath = path.join(outDir, 'build-time.json'); verbose(); verbose('>>> begin executing verbose build-time script <<<'.white().dim().italic()); verbose('\n', ' --verbose '.bgGreen().black().bold(), 'enabled!'.green().bold(), '\n'); verbose(' filePath: '.green().bold(), path.resolve(__filename).blue().bold()); // Create directory and files fs.mkdirSync(outDir, {recursive: true}); if (!fs.existsSync(outDir)) { error("ERROR:".bgRed().white().bold(), "Failed to access 'out' directory.".red()); exit(1); } // Write JSON file fs.writeFileSync(jsonFilePath, JSON.stringify(buildTimeData, null, 2)); if (!fs.existsSync(jsonFilePath)) { error("ERROR:".bgRed().white().bold(), "Failed to write build time file.".red()); exit(1); } if (flags.verbose) { verbose(' ✓ build-time script executed successfully!'.green().bold()); verbose(' ✓ created JSON file:'.green().bold(), jsonFilePath.cyan()); verbose(' ✓ relative filepath:'.green().bold(), path.relative(process.cwd(), jsonFilePath).cyan()); verbose(' content:'.green().black().bold(), JSON.stringify(buildTimeData, null, 2).blue().bold()); verbose(' last modified:'.green().black().bold(), fs.statSync(jsonFilePath).mtime.toLocaleString().blue().dim()); verbose(); verbose('>>> end executing verbose build-time script <<<'.white().dim().italic()); verbose(); } log('✓ created JSON at:'.bgGreen().black().bold(), path.basename(jsonFilePath).cyan().dim()); log('✓ with timestamp: '.bgGreen().black().bold(), timestamp.blue().dim()); } catch (error) { error("ERROR:".bgRed().white().bold(), "Script execution failed.".red()); flags.verbose && console.error('Details:'.bgRed().white().bold(), error.message.red()); exit(1); } ================================================ FILE: scripts/dev-complete.fish ================================================ #!/usr/bin/env fish set -l DIR (status current-filename | path resolve | path dirname) source "$DIR/fish/pretty-print.fish" argparse install uninstall -- $argv or return set -l cached_file ~/.config/fish/conf.d/tmp-fish-lsp.fish set -l workspace_root (path dirname (path dirname -- (status current-filename)) | path resolve) if set -ql _flag_uninstall if test -f $cached_file echo "Uninstalling completions for fish-lsp..." rm -f $cached_file echo "Completions uninstalled." else echo "No completions found to uninstall." end exit 0 end if set -q INSTALL_DEV_COMPLETIONS && test "$INSTALL_DEV_COMPLETIONS" = "true" || set -q _flag_install echo "Installing completions for fish-lsp..." else echo "Skipping completions installation for fish-lsp." exit 0 end echo " if not string match -rq -- '^$workspace_root' \"\$PWD\" exit end " > $cached_file # Append each completion to the cached file yarn -s run dev -c >> $cached_file # yarn -s run tag-and-publish -c >>$cached_file # yarn -s run publish-and-release -c >>$cached_file yarn -s run publish-nightly -c >>$cached_file node ./scripts/build-time -c >>$cached_file yarn -s run sh:workspace-cli -c >>$cached_file yarn -s run generate:snippets -c >>$cached_file # fish ./scripts/build-assets.fish --complete >>$cached_file print_success "Generated fish-lsp development completions in $BLUE$cached_file$NORMAL" source ~/.config/fish/config.fish # Alternative approach using psub (sources completions dynamically without creating intermediate file) # Uncomment to use psub instead of cached file: # source (yarn -s run dev -c | psub) # source (yarn -s run publish-and-release -c | psub) # source (yarn -s run publish-nightly -c | psub) # source (node ./scripts/build-time -c | psub) # source (yarn -s run sh:workspace-cli -c | psub) # source (yarn -s run generate:snippets -c | psub) source $cached_file exec fish ================================================ FILE: scripts/esbuild/cli.ts ================================================ // Improved CLI argument parsing import { Command } from 'commander'; import { BuildTarget, WatchMode, SourcemapMode, VALID_WATCH_MODES, VALID_SOURCEMAP_MODES } from './types'; export interface BuildArgs { target: BuildTarget; watch: boolean; watchAll: boolean; watchMode: WatchMode; production: boolean; minify: boolean; enhanced: boolean; fishWasm: boolean; typesOnly: boolean; sourcemaps: SourcemapMode; specialSourceMaps: boolean; } export function parseArgs(): BuildArgs { const program = new Command(); program .name('dev-esbuild') .description('Fish LSP development build system using esbuild') .option('-w, --watch', 'Watch for changes to all relevant files and run full build', false) .option('--watch-all', 'Watch for changes to all relevant files and run full build (same as --watch)', false) .option('--mode ', 'Watch mode type: dev (default), lint, npm, types, binary, all, test', 'dev') .option('-p, --production', 'Production build (minified, optimized sourcemaps)', false) .option('-c, --completions', 'Show shell completions for this command', false) .option('-m, --minify', 'Minify output', true) .option('--sourcemaps ', 'Sourcemap type: optimized (default), extended (full debug), none, special (src-only)', 'optimized') .option('--special-source-maps', 'Enable special sourcemap processing (src files only with content)', false) .option('--all', 'Build all targets: development, binary, npm, and web', false) .option('--binary, --bin', 'Create bundled binary in build/', false) .option('--npm', 'Create NPM package build with external dependencies', false) .option('--web', 'Create web bundle with Node.js polyfills for browser usage', false) .option('--fish-wasm', 'Create web bundle with full Fish shell via WASM', false) .option('--enhanced', 'Use enhanced web build with Fish WASM', false) .option('--types', 'Generate TypeScript declaration files only', false) .option('--ci', 'Run CI/CD test on fresh install', false) .option('--fresh', 'fresh install', false) .option('--setup', 'setup & install dependencies without building', false) .option('-h, --help', 'Show help message'); program.parse(); const options = program.opts(); // Check if any target flag was explicitly provided const hasTargetFlag = options.types || options.all || options.binary || options.bin || options.npm || options.library || options.test || options.web || options.fishWasm; // Determine target based on flags // Default to 'all' if no target flags are provided for backwards compatibility let target: BuildTarget = hasTargetFlag ? 'development' : 'all'; if (options.types) target = 'types'; else if (options.all) target = 'all'; else if (options.binary || options.bin) target = 'binary'; else if (options.npm) target = 'npm'; else if (options.library) target = 'library'; else if (options.test) target = 'test'; else if (options.ci) target = 'ci'; else if (options.fresh) target = 'fresh'; else if (options.setup) target = 'setup'; // else if (options.web || options.fishWasm) target = 'web'; // Validate sourcemaps option let sourcemaps: SourcemapMode = (VALID_SOURCEMAP_MODES as readonly string[]).includes(options.sourcemaps) ? options.sourcemaps : 'optimized'; // Override sourcemaps if special flag is used if (options.specialSourceMaps) { sourcemaps = 'special'; } // Validate watchMode if (!(VALID_WATCH_MODES as readonly string[]).includes(options.mode)) { throw new Error(`Invalid watch mode: ${options.mode}. Must be one of: ${VALID_WATCH_MODES.join(', ')}`); } return { target, watch: options.watch, watchAll: options.watchAll, watchMode: options.mode as WatchMode, production: options.production, minify: options.minify, enhanced: options.enhanced, fishWasm: options.fishWasm, typesOnly: options.types, sourcemaps, specialSourceMaps: options.specialSourceMaps, }; } export function showHelp(): void { console.log(` Usage: tsx scripts/build.ts [options] Options: --watch, -w Watch for changes to all relevant files and run full build --watch-all Watch for changes to all relevant files and run full build (same as --watch) --mode Watch mode type: dev (default), lint, npm, types, binary, all, test --binary, --bin Create bundled binary in bin/fish-lsp (used for GitHub releases) --npm Create NPM package build with external dependencies (used for npm publishing) --web Create web bundle with Node.js polyfills for browser usage --fish-wasm Create web bundle with full Fish shell via WASM (large bundle, not yet supported) --enhanced Use enhanced web build with Fish WASM --types Generate TypeScript declaration files only --ci Run CI/CD test on fresh install (installs from npm and runs test build) --fresh Fresh install (installs from npm and runs test build, same as --ci) --setup Setup & install dependencies without building (installs from npm and exits) --all Build all targets: development, binary, npm --production, -p Production build (minified, optimized sourcemaps) --minify, -m Minify output --sourcemaps Sourcemap type: optimized (default), extended (full debug), none, special (src-only) --special-source-maps Enable special sourcemap processing (src files only with content) --help, -h Show this help message Examples: tsx scripts/build.ts # Build all targets (default) tsx scripts/build.ts --watch # Watch all files and run full build on changes tsx scripts/build.ts --watch-all # Watch all files and run full build on changes (same as --watch) tsx scripts/build.ts --watch-all --mode=npm # Watch files and run npm build on changes tsx scripts/build.ts --watch-all --mode=types # Watch files and run types build on changes tsx scripts/build.ts --binary # Create bundled binary tsx scripts/build.ts --bin # Create bundled binary (alias for --binary) tsx scripts/build.ts --npm # Create NPM package build tsx scripts/build.ts --types # Generate TypeScript declaration files only tsx scripts/build.ts --all # Build all targets tsx scripts/build.ts --production # Production build with optimized sourcemaps tsx scripts/build.ts --sourcemaps=extended # Development build with full debug sourcemaps tsx scripts/build.ts --sourcemaps=none # Build without sourcemaps tsx scripts/build.ts --special-source-maps # Build with special sourcemaps (src files only) # Or use yarn scripts: yarn dev --binary # Create bundled binary yarn dev --npm # Create NPM package build yarn dev --types # Generate TypeScript declarations yarn dev --all # Build all targets yarn build:watch # Watch all files (equivalent to --watch-all) yarn build:watch --mode=npm # Watch files and run npm build on changes yarn build:watch --mode=test # Watch files and test build on changes `); } export function showCompletions(): void { console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -f`) console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -s w -l watch -d "Watch for changes and rebuild (esbuild only)"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l watch-all -d "Watch for changes to all relevant files and run full build"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l mode -d "Watch mode type" -x -a "dev lint npm types binary all test"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l all -d "Build all targets: development, binary, npm"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l binary -d "Create bundled binary in bin/fish-lsp"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l bin -d "Create bundled binary in bin/fish-lsp (alias for --binary)"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l npm -d "Create NPM package build with external dependencies"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l web -d "Create web bundle with Node.js polyfills for browser usage"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l fish-wasm -d "Create web bundle with full Fish shell via WASM"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l enhanced -d "Use enhanced web build with Fish WASM"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l types -d "Generate TypeScript declaration files only"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l ci -d "Run CI/CD tests on fresh install"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l fresh -d "Reinstall with fresh dependencies"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l setup -d "Reinstall with fresh dependencies && build required dependencies (no build targets)"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l production -d "Production build (minified, optimized sourcemaps)"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l minify -d "Minify output"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -l special-source-maps -d "Enable special sourcemap processing (src files only with content)"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -s h -l help -d "Show help message"`); console.log(`complete -c yarn -n "__fish_seen_subcommand_from dev" -s c -l completions -d "Show shell completions for this command"`); } ================================================ FILE: scripts/esbuild/colors.ts ================================================ // ANSI color utilities for terminal output import path from 'path'; import process from 'process'; // Helper to convert absolute paths to relative paths from project root export function toRelativePath(filePath: string): string { return path.relative(path.resolve(process.cwd()), filePath); } export const colors = { // Basic colors reset: '\x1b[0m', bright: '\x1b[1m', bold: '\x1b[1m', b: '\x1b[1m', dim: '\x1b[2m', underline: '\x1b[4m', // Text colors black: '\x1b[30m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m', // Background colors bgBlack: '\x1b[40m', bgRed: '\x1b[41m', bgGreen: '\x1b[42m', bgYellow: '\x1b[43m', bgBlue: '\x1b[44m', bgMagenta: '\x1b[45m', bgCyan: '\x1b[46m', bgWhite: '\x1b[47m', }; // Check if we should use colors (respects NO_COLOR env var and TTY detection) const shouldUseColors = !process.env.NO_COLOR && process.stdout.isTTY; export function colorize(text: string, color: string): string { if (!shouldUseColors) return text; return `${color}${text}${colors.reset}`; } // Utility functions for common color patterns export const logger = { success: (text: string) => colorize(text, colors.green), error: (text: string) => colorize(text, colors.red), warning: (text: string) => colorize(text, colors.yellow), info: (text: string) => colorize(text, colors.blue), debug: (text: string) => colorize(text, colors.gray), highlight: (text: string) => colorize(text, colors.cyan), bold: (text: string) => colorize(text, colors.bright), dim: (text: string) => colorize(text, colors.dim), // Status indicators building: (target: string) => `${colorize('⚡', colors.yellow)} Building ${colorize(target, colors.cyan)}...`, watching: (target: string) => `${colorize(' ', colors.blue)} Watching ${colorize(target, colors.cyan)} for changes...`, complete: (target: string) => `${colorize(' ', colors.green)} ${colorize(target, colors.cyan)} build complete!`, failed: (target: string) => `${colorize(' ', colors.red)} ${colorize(target, colors.cyan)} build failed!`, // File operations copied: (from: string, to?: string) => `${colorize(' ', colors.cyan)} Copied ${colorize(toRelativePath(from), colors.dim)}${to ? ` → ${colorize(toRelativePath(to), colors.dim)}` : ''}`, generated: (file: string) => `${colorize(' ', colors.cyan)} Generated ${colorize(toRelativePath(file), colors.dim)}`, executable: (file: string) => `${colorize(' ', colors.green)} Made executable: ${colorize(toRelativePath(file), colors.dim)}`, // Statistics size: (label: string, size: string, path?: string) => { const sizeColored = colorize(size, colors.yellow); const labelColored = colorize(label, colors.cyan); const pathColored = path ? colorize(path, colors.dim) : ''; return `${colorize(' ', colors.blue)} ${labelColored} size: ${sizeColored}${path ? ` (${pathColored})` : ''}`; }, // Progress indicators step: (current: number, total: number, description: string) => { const progress = colorize(`[${current}/${total}]`, colors.white); const desc = colorize(description, colors.cyan); return `${progress} ${desc}`; }, time: (text: string) => { // alt icon:  return `${logger.success(' ')} ${colorize(text, colors.dim)}`; }, // Headers and sections header: (text: string) => colorize(`${text}`, colors.bright + colors.cyan), section: (text: string) => colorize(text, colors.bright), // Raw logging with color support log: (message: string, color?: keyof typeof colors) => { const colored = color ? colorize(message, colors[color]) : message; console.log(colored); }, // Error handling warn: (message: string) => console.warn(colorize(` ${message}`, colors.yellow)), logError: (message: string, error?: Error) => { console.error(colorize(` ${message}`, colors.red)); if (error && process.env.DEBUG) { console.error(colorize(error.stack || error.message, colors.red + colors.dim)); } } }; // Setup colors export function enableColors() { return String.prototype; } Object.keys(colors).forEach(color => { String.prototype[color] = () => { return `${colors[color]}${this}${colors.reset}` }; Object.defineProperty(String.prototype, color, { get: function() { return colors[color] + this + colors.reset; }, configurable: true // Allows redefinition or deletion }); }) declare global { interface String { red: string; green: string; yellow: string; blue: string; magenta: string; cyan: string; white: string; gray: string; black: string; // @ts-ignore bold: string; b: string; dim: string; bright: string; underline: string; bgRed: string; bgGreen: string; bgYellow: string; bgBlue: string; bgMagenta: string; bgCyan: string; bgWhite: string; bgBlack: string; } } ================================================ FILE: scripts/esbuild/configs.ts ================================================ // Centrfalized build configurations import esbuild from 'esbuild'; import { resolve } from 'path'; import { createPlugins, createDefines, PluginOptions, createSourceMapOptimizationPlugin, createSpecialSourceMapPlugin } from './plugins'; import { BuildConfigTarget, SourcemapMode } from "./types"; export interface BuildConfig extends esbuild.BuildOptions { name: string; entryPoint: string; outfile?: string; outdir?: string; target: string; format: 'cjs' | 'esm'; platform: 'node' | 'browser'; bundle: boolean; minify: boolean; sourcemap: boolean | 'inline' | 'external'; external?: string[]; plugins?: esbuild.Plugin[]; internalPlugins: PluginOptions; onBuildEnd?: () => void; } export const buildConfigs: Record = { binary: { name: 'Universal Binary', entryPoint: 'src/main.ts', outfile: resolve('bin', 'fish-lsp'), target: 'node', format: 'cjs', platform: 'node', bundle: true, treeShaking: true, minify: true, assetNames: 'assets/[name]-[hash]', // Include hash in asset names for cache busting loader: { '.wasm': 'file', '.node': 'file', '.fish': 'text', }, sourcemap: true, // Generate external source maps for debugging preserveSymlinks: true, // Bundle @ndonfris/tree-sitter-fish for binary builds external: [], internalPlugins: { target: 'node', typescript: false, // Use native esbuild TS support polyfills: 'minimal', // Include minimal polyfills for browser compatibility when needed embedAssets: true, // Enable embedded assets for binary builds }, onBuildEnd: () => { } }, development: { name: 'Development', entryPoint: 'src/**/*.ts', outdir: 'out', target: 'node', format: 'cjs', platform: 'node', bundle: false, minify: false, sourcemap: true, internalPlugins: { target: 'node', typescript: false, // Use tsc separately polyfills: 'none', }, }, npm: { name: 'NPM Package', entryPoint: 'src/main.ts', outfile: resolve('dist', 'fish-lsp'), target: 'node20', format: 'cjs', platform: 'node', bundle: true, treeShaking: true, minify: true, assetNames: 'assets/[name]-[hash]', loader: { '.wasm': 'file', '.node': 'file', '.fish': 'text', }, sourcemap: true, preserveSymlinks: true, // External dependencies - don't bundle these, npm will provide them external: [ '@esdmr/tree-sitter-fish', 'chalk', 'commander', 'fast-glob', 'fs-extra', 'vscode-languageserver', 'vscode-languageserver-protocol', 'vscode-languageserver-textdocument', 'vscode-uri', 'web-tree-sitter', 'zod' ], internalPlugins: { target: 'node', typescript: false, polyfills: 'minimal', embedAssets: true, // Keep WASM files embedded }, }, }; export function createBuildOptions(config: BuildConfig, production = false, sourcemapsMode: SourcemapMode | 'inline' | 'inline-optimized' = 'inline-optimized'): esbuild.BuildOptions { // Configure sourcemaps based on mode const shouldGenerateSourceMaps = config.sourcemap !== false && sourcemapsMode !== 'none'; const isInlineMode = sourcemapsMode === 'inline' || sourcemapsMode === 'inline-optimized'; const sourcemapSetting: esbuild.BuildOptions['sourcemap'] = shouldGenerateSourceMaps ? (isInlineMode ? 'inline' : 'external') : false; return { entryPoints: config.bundle ? [config.entryPoint] : [config.entryPoint], bundle: config.bundle, platform: config.platform, target: config.target === 'node' ? 'node18' : 'es2020', format: config.format, loader: config.loader, assetNames: config.assetNames, preserveSymlinks: config.preserveSymlinks, ...(config.outfile ? { outfile: config.outfile } : { outdir: config.outdir }), minify: config.minify && production, sourcemap: sourcemapSetting, sourcesContent: sourcemapsMode !== 'inline-optimized', // Exclude sources for optimized inline mode keepNames: !production, treeShaking: config.bundle ? true : production, external: config.external, define: createDefines(config.target, production), // Performance optimizations for startup speed splitting: false, // Disable code splitting for faster startup metafile: false, // Disable metadata generation legalComments: 'none', // Remove legal comments for smaller bundles ignoreAnnotations: false, // Keep function annotations for V8 optimization // mangleProps: false, // Don't mangle properties to avoid runtime overhead plugins: [ ...createPlugins(config.internalPlugins), // Always use the special sourcemap plugin for bundled builds (but skip for inline sourcemaps) config.bundle && !isInlineMode ? createSpecialSourceMapPlugin({ preserveOnlySrcContent: true }) : !isInlineMode ? createSourceMapOptimizationPlugin(sourcemapsMode === 'extended') : { name: 'no-sourcemap-plugin', setup() { } }, // Inline sourcemaps don't need post-processing ...(config.onBuildEnd ? [{ name: 'build-end-hook', setup(build: esbuild.PluginBuild) { build.onEnd(config.onBuildEnd!); }, }] : []), ], }; } ================================================ FILE: scripts/esbuild/file-watcher.ts ================================================ import { spawn, ChildProcess } from 'child_process'; import { colorize, colors, logger } from './colors'; import { WatchMode, TargetInfo, getTarget, findTarget, keyboardTargets } from './types'; import chokidar from 'chokidar'; import fastGlob from 'fast-glob'; // Utility to kill process tree (handles child processes) function killProcessTree(pid: number, signal: string = 'SIGTERM'): void { try { // On Unix systems, kill the process group if (process.platform !== 'win32') { // Kill the process group (negative PID targets the process group) process.kill(-pid, signal as any); // Also kill the main process directly as a fallback try { process.kill(pid, signal as any); } catch (e) { // Process may already be dead } } else { // On Windows, use taskkill to terminate the process tree const taskKillOptions = signal === 'SIGKILL' ? ['/pid', pid.toString(), '/T', '/F'] : ['/pid', pid.toString(), '/T']; spawn('taskkill', taskKillOptions, { stdio: 'ignore' }); } } catch (error) { // Process may already be dead, ignore errors but try direct kill as fallback try { process.kill(pid, signal as any); } catch (e) { // Really dead now, ignore } } } // ============================================================================ // UI Helpers // ============================================================================ const separator = () => { console.log(colorize('━'.repeat(Math.max(90, Number.parseInt(process.env['COLUMNS'] || '89', 10))), colors.blue)); }; const showHelp = (currentMode?: WatchMode) => { separator(); console.log(logger.info(' Available Commands:')); console.log(` * ${'[H]'.green} - Show this help`); console.log(` * ${'[M]'.blue} - Switch watch mode`); console.log(` * ${'[W]'.magenta} - Show watched file paths`); console.log(` * ${'[Enter|A|R]'.white} - Run current mode build`); for (const t of keyboardTargets) { const entry = TargetInfo.helpEntry(t); if (entry) { console.log(` * ${entry.key.padEnd(13)[entry.color]}- ${entry.text}`); } } console.log(` * ${'[Q|Ctrl+C]'.red} - Quit watch mode`); console.log(''); if (currentMode) { console.log(` Current Mode: ${getTarget(currentMode).description.bgBlue.black.underline.dim}`); } separator(); }; const log = (...args: string[]) => { console.log(args.join(' ')); } const showKeysReminder = (currentMode?: WatchMode) => { const modeText = currentMode ? `[${getTarget(currentMode).description}]` : ''; console.log(`Press ${"[H]".bgGreen.black.dim} for help, ${"[M]".bgCyan.black.dim} for mode switch, ${"[Enter]".bgBlue.black.dim} to rebuild ${modeText.bgBlue.black.dim}`); }; // ============================================================================ // Build Manager - Handles all build operations consistently // ============================================================================ type BuildTrigger = 'file-change' | 'manual' | 'mode-switch'; class BuildManager { private currentProcess: ChildProcess | null = null; private isBuilding = false; private buildCount = 0; private currentMode: WatchMode = 'dev'; async runBuild(type: WatchMode, trigger: BuildTrigger): Promise { if (this.isBuilding) { console.log(logger.dim('⏳ Build already in progress, please wait...')); return; } this.isBuilding = true; this.buildCount++; const info = getTarget(type); const command = [...info.command]; const buildName = info.label; this.currentMode = type; separator(); console.log(logger.info(`🔄 ${buildName} rebuild triggered (${trigger})...`)); separator(); try { await this.executeCommand(['yarn', ...command]); this.showCompletionMessage(type, trigger, true); } catch (error) { this.showCompletionMessage(type, trigger, false, error as Error); } finally { this.isBuilding = false; } } private executeCommand(args: string[]): Promise { return new Promise((resolve, reject) => { // Join the command and arguments into a single command string for shell execution const command = args.join(' '); this.currentProcess = spawn(command, [], { stdio: 'inherit', cwd: process.cwd(), shell: true, detached: process.platform !== 'win32', // Use process groups on Unix killSignal: 'SIGTERM' }); // On Unix, create a new process group if (process.platform !== 'win32' && this.currentProcess.pid) { try { process.kill(this.currentProcess.pid, 0); // Check if process exists } catch (error) { // Process creation failed reject(error); return; } } this.currentProcess.on('close', (code: number) => { this.currentProcess = null; if (code === 0) { resolve(); } else { reject(new Error(`Process exited with code ${code}`)); } }); this.currentProcess.on('error', (error: Error) => { this.currentProcess = null; reject(error); }); }); } private showCompletionMessage(type: WatchMode, trigger: BuildTrigger, success: boolean, error?: Error): void { separator(); const buildName = getTarget(type).label; this.currentMode = type; if (success) { console.log(`${buildName} Rebuild Completed`.bright); } else { console.log(`${buildName} Rebuild Failed`.red); } separator(); if (success) { console.log(` ${buildName} rebuild completed successfully!`.white); } else if (error) { console.log(` Rebuild failed:`.red, error.message.dim); } console.log(` Build timestamp:`.white, new Date().toLocaleTimeString().yellow); console.log(` Total rebuilds:`.white, `${this.buildCount}`.blue); console.log(` Trigger:`.white, trigger.magenta); console.log(` Current mode:`.white, getTarget(this.currentMode).description.cyan); separator(); showKeysReminder(this.currentMode); } cancel(): boolean { if (this.currentProcess && this.currentProcess.pid) { console.log(logger.warning(' Cancelling current build...')); const pid = this.currentProcess.pid; // Kill the entire process tree (including child processes) killProcessTree(pid, 'SIGTERM'); // Force kill after timeout if process doesn't exit const forceKillTimeout = setTimeout(() => { if (this.currentProcess && !this.currentProcess.killed) { console.log(logger.warning('🔥 Force killing build process tree...')); killProcessTree(pid, 'SIGKILL'); } }, 2000); // 2 second timeout // Clear timeout if process exits normally this.currentProcess.on('exit', () => { clearTimeout(forceKillTimeout); }); this.currentProcess = null; this.isBuilding = false; return true; } return false; } get building(): boolean { return this.isBuilding; } get mode(): WatchMode { return this.currentMode; } setMode(mode: WatchMode): void { this.currentMode = mode; } } // ============================================================================ // File Watcher - Simplified using chokidar // ============================================================================ interface WatchConfig { watchPaths: string[]; ignorePatterns: string[]; debounceMs: number; } class FileWatcher { // @ts-ignore private watcher: chokidar.FSWatcher | null = null; private debounceTimer: NodeJS.Timeout | null = null; private readonly config: WatchConfig; private readonly buildManager: BuildManager; constructor(config: WatchConfig, buildManager: BuildManager) { this.config = config; this.buildManager = buildManager; } start(): void { console.log(logger.info('Starting file watcher...')); console.log(logger.dim(`Current working directory: ${process.cwd()}`)); console.log(logger.dim(`Watching: ${this.config.watchPaths.join(', ')}`)); console.log(logger.dim(`Debounce: ${this.config.debounceMs}ms`)); const toAdd: string[] = [] // Debug: test if patterns match any files console.log(logger.dim('Testing glob patterns:')); // Resolve globs to actual file paths this.config.watchPaths.forEach(pattern => { try { const matches = fastGlob.sync(pattern, { ignore: this.config.ignorePatterns }); console.log(logger.dim(` ${pattern} -> ${matches.length} files`)); matches.forEach((m: string) => { if (!toAdd.includes(m)) { toAdd.push(m); } }) } catch (e) { console.log(logger.dim(` ${pattern} -> ERROR: ${e.message}`)); } }); // Create chokidar watcher with globbing enabled this.watcher = chokidar.watch(toAdd, { ignored: this.config.ignorePatterns, ignoreInitial: true, persistent: false, // @ts-ignore disableGlobbing: true, // We already resolved globs with fast-glob usePolling: false, useFsEvents: true, // Use native filesystem events for better case sensitivity interval: 1000, alwaysStat: true, }); // Run initial build in current mode this.buildManager.runBuild(this.buildManager.mode, 'file-change'); // Set up event handlers this.watcher .on('ready', () => { console.log(logger.success('File watcher ready and monitoring for changes')); // Debug: show what files are being watched const watchedPaths = this.watcher?.getWatched(); if (watchedPaths) { const totalFiles = Object.values(watchedPaths).reduce((sum: number, files) => Array.isArray(files) ? sum + files.length : sum, 0); console.log(logger.dim(`Watching ${totalFiles} files across ${Object.keys(watchedPaths).length} directories`)); } }) .on('change', (path: string) => { log(colorize(logger.dim(` File changed:`), colors.green), colorize(logger.bold(path), colors.yellow)); this.handleFileChange('change', path); }) .on('add', (path: string) => { log(colorize(logger.dim(`➕ File added:`), colors.green), logger.bold(path)); this.handleFileChange('add', path); }) .on('unlink', (path: string) => { log(colorize(logger.dim(`➖ File removed:`), colors.red), (logger.highlight(`${path}`))); this.handleFileChange('unlink', path); }) .on('addDir', (path: string) => { log(colorize(logger.dim(`➕ Directory added`), colors.green), logger.bold(path)); this.handleFileChange('addDir', path); }) .on('unlinkDir', (path: string) => { log(colorize(logger.dim(`➖ Directory removed`), colors.red), logger.bold(path)); this.handleFileChange('unlinkDir', path); }) .on('error', (error: Error) => { console.error(logger.error('File watcher error:'), error); }); } private handleFileChange(event: string, filePath: string): void { // Extract filename for display const filename = filePath.split('/').pop() || filePath; log(colorize(` ${event}:`, colors.white), colorize(filename, colors.yellow)); // Debounce rebuilds - clear existing timer and set new one if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout(async () => { console.log(logger.building('Rebuilding due to file changes...')); await this.buildManager.runBuild(this.buildManager.mode, 'file-change'); }, this.config.debounceMs); } showWatchedPaths(): void { if (!this.watcher) { console.log(logger.warning('File watcher is not active')); return; } const watchedPaths: { [regexStr: string]: string[] } = this.watcher.getWatched(); if (!watchedPaths) { console.log(logger.warning('No watched paths available')); return; } separator(); console.log(logger.info('Currently Watched Files:')); separator(); const totalFiles = Object.values(watchedPaths).reduce((sum: number, files) => Array.isArray(files) ? sum + files.length : sum, 0); // ^? const totalDirs = Object.keys(watchedPaths).length; console.log(`Total: ${totalFiles.toString().b.green} files across ${totalDirs.toString().b.cyan} directories\n`); // Group and display by directory Object.keys(watchedPaths).sort().forEach(dir => { const files = watchedPaths[dir]; if (files.length > 0) { console.log(colorize(dir, colors.blue)); files.forEach(file => { console.log(logger.dim(` ${file}`)); }); console.log(''); } }); separator(); } stop(): void { console.log(''.red.b + ' ' + logger.warning('Stopping file watcher...')); // Cancel any running build this.buildManager.cancel(); // Clear debounce timer if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } // Close chokidar watcher if (this.watcher) { this.watcher.close(); this.watcher = null; } } } // ============================================================================ // Keyboard Handler - Simplified input handling // ============================================================================ class KeyboardHandler { private readonly buildManager: BuildManager; private readonly fileWatcher: FileWatcher; private _activePanel: 'help' | 'mode' | null = null; constructor(buildManager: BuildManager, fileWatcher: FileWatcher) { this.buildManager = buildManager; this.fileWatcher = fileWatcher; } setup(): void { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.on('data', this.handleKeyPress.bind(this)); process.stdin.on('error', (err) => { console.error('Error reading stdin:', err); }); } private async handleKeyPress(key: string): Promise { const keyCode = key.toLowerCase(); // Mode selection has its own once() handler — ignore main handler input if (this._activePanel === 'mode') return; // Any non-help key clears the help panel state if (this._activePanel === 'help' && keyCode !== 'h') { this._activePanel = null; } switch (keyCode) { case '\u0003': // Ctrl+C case 'q': this.exit(); break; case 'h': if (this._activePanel === 'help') { this._activePanel = null; showKeysReminder(this.buildManager.mode); } else { this._activePanel = 'help'; showHelp(this.buildManager.mode); } break; case 'm': this._activePanel = 'mode'; await this.showModeSelection(); break; case 'w': this.fileWatcher.showWatchedPaths(); break; case '\r': // Enter case '\n': case 'a': case 'r': await this.buildManager.runBuild(this.buildManager.mode, 'manual'); break; default: { const target = findTarget(keyCode); if (target) { await this.buildManager.runBuild(target.name as WatchMode, 'mode-switch'); } break; } } } private async showModeSelection(): Promise { console.log('\n' + 'Select Watch Mode:'.bright.underline.magenta + '\n'); for (const t of keyboardTargets) { const entry = TargetInfo.helpEntry(t); if (entry) { console.log(`\t${entry.key.padEnd(10)[entry.color]} ${t.description}`); } } console.log('\n\t' + logger.dim('Current: ') + getTarget(this.buildManager.mode).description.bgBlue.black.b + '\n'); console.log(logger.highlight(`Enter number (1-${keyboardTargets.length}) or key to switch, any other key to cancel:`)); // Set up temporary key listener for mode selection const modeSelectionHandler = (key: string) => { process.stdin.removeListener('data', modeSelectionHandler); this._activePanel = null; const choice = key.trim().toLowerCase(); // 'm' toggles the menu closed if (choice === 'm') { showKeysReminder(this.buildManager.mode); return; } const target = findTarget(choice); if (!target) { console.log(logger.dim('Mode selection cancelled.')); showKeysReminder(this.buildManager.mode); return; } const newMode = target.name as WatchMode; if (newMode !== this.buildManager.mode) { this.buildManager.setMode(newMode); console.log(logger.success(`Switched to: ${getTarget(newMode).description}`)); console.log(logger.info('File changes will now trigger: ' + getTarget(newMode).description)); } else { console.log(logger.dim('Already in that mode.')); } separator(); showKeysReminder(this.buildManager.mode); }; process.stdin.once('data', modeSelectionHandler); } private exit(): void { console.log('\n' + ''.red.b + ' ' + logger.warning('Exiting watch mode...')); // Restore stdin if (process.stdin.setRawMode) { process.stdin.setRawMode(false); } process.stdin.pause(); // Stop file watcher this.fileWatcher.stop(); process.exit(0); } } // ============================================================================ // Main Export - Simple interface // ============================================================================ export async function startFileWatcher(initialMode: WatchMode = 'dev'): Promise { // Create managers const buildManager = new BuildManager(); buildManager.setMode(initialMode); const fileWatcher = new FileWatcher({ watchPaths: [ 'src/*.ts', 'src/**/*.ts', 'src/**/*.json', 'fish_files/**/*', 'scripts/**/*.ts', 'scripts/**/*', 'scripts/*.fish', 'fish_files/*.fish', 'package.json', 'tsconfig.json', 'vitest.config.ts' ], ignorePatterns: [ '**/node_modules/**', '**/temp-embedded-assets/**', '**/out/**', '**/dist/**', '**/lib/**', '**/.git/**', '**/coverage/**', '**/*.tgz', '**/.tsbuildinfo', '**/logs.txt', '**/*.map', '**/.bun/**', // Additional common ignores '**/.DS_Store', '**/Thumbs.db', '**/*.tmp', '**/*.temp' ], debounceMs: 1000, }, buildManager); const keyboardHandler = new KeyboardHandler(buildManager, fileWatcher); // Setup comprehensive signal handling const cleanup = (signal: string) => { console.log(`\n${logger.info(`Received ${signal}, cleaning up...`)}`); // Cancel any running builds first buildManager.cancel(); // Stop file watcher fileWatcher.stop(); // Force exit to ensure we don't hang setTimeout(() => { process.exit(1); }, 1000); // Exit cleanly process.exit(0); }; // Handle various termination signals process.on('SIGTERM', () => cleanup('SIGTERM')); process.on('SIGINT', () => cleanup('SIGINT')); process.on('SIGHUP', () => cleanup('SIGHUP')); // Handle uncaught exceptions to prevent zombie processes process.on('uncaughtException', (error) => { console.error(logger.error('Uncaught exception:'), error); cleanup('uncaughtException'); }); process.on('unhandledRejection', (reason, promise) => { console.error(logger.error('Unhandled rejection at:'), promise, 'reason:', reason); cleanup('unhandledRejection'); }); // Start everything fileWatcher.start(); keyboardHandler.setup(); console.log(logger.success('File watcher started!')); console.log([`Current mode:`.underline.green, `${getTarget(buildManager.mode).description.bgBlue.black.underline.b}`].join(' ')); separator(); showKeysReminder(buildManager.mode); separator(); // Keep process running return new Promise(() => { }); // Never resolves } ================================================ FILE: scripts/esbuild/index.ts ================================================ #!/usr/bin/env tsx import { parseArgs, showCompletions, showHelp } from "./cli"; import { pipeline } from './pipeline'; import { startFileWatcher } from './file-watcher'; import { logger } from './colors'; export async function build(_customArgs?: string[]): Promise { const args = parseArgs(); // Handle help and completions if (process.argv.includes('--help') || process.argv.includes('-h')) { showHelp(); process.exit(0); } if (process.argv.includes('--completions') || process.argv.includes('-c')) { showCompletions(); process.exit(0); } // Handle comprehensive file watching if (args.watchAll || args.watch) { console.log(logger.header('`fish-lsp` comprehensive file watcher')); console.log(logger.info('Starting comprehensive file watcher...')); await startFileWatcher(args.watchMode); return; } try { // Execute the build pipeline for the target await pipeline.execute(args.target, args); } catch (error) { logger.logError('Build failed', error as Error); process.exit(1); } } build(); ================================================ FILE: scripts/esbuild/pipeline.ts ================================================ import { execSync } from 'child_process'; import esbuild from 'esbuild'; import { BuildArgs } from './cli'; import { logger } from './colors'; import { buildConfigs, createBuildOptions } from './configs'; import { copyDevelopmentAssets, ensureDirectoryExists, generateTypeDeclarations, isFileEmpty, makeExecutable, showBuildStats, showDirectorySize } from './utils'; interface BuildStep { name: string; priority: number; tags: string[]; // What meta-targets include this step condition?: (args: BuildArgs) => boolean; runner: (args: BuildArgs) => Promise | void; timing?: boolean; postBuild?: (args: BuildArgs) => Promise | void; } class BuildPipeline { private steps: BuildStep[] = []; register(step: BuildStep): this { this.steps.push(step); return this; } async execute(target: string, args: BuildArgs): Promise { const applicableSteps = this.getStepsForTarget(target, args); if (applicableSteps.length === 0) { throw new Error(`No build steps found for target: ${target}`); } // Show header once at the beginning console.log(logger.header('`fish-lsp` esbuild (BUILD SYSTEM)')); console.log(logger.info(`Building ${applicableSteps.length} targets...`)); // Execute all steps with correct numbering for (let i = 0; i < applicableSteps.length; i++) { const step = applicableSteps[i]; console.log(`\n${logger.step(i + 1, applicableSteps.length, logger.building(step.name))}`); const startTime = Date.now(); try { await step.runner(args); if (step.postBuild) { await step.postBuild(args); } } catch (error) { console.log(logger.failed(step.name)); console.error('Error details:', error); throw error; } if (step.timing) { const buildTime = Date.now() - startTime; console.log(logger.time(`${step.name} built in ${buildTime} ms`)); } } console.log(`\n${logger.success('All builds completed successfully!')}`); } // Get steps for a specific target - useful for other scripts getStepsForTarget(target: string, args: BuildArgs): BuildStep[] { return this.steps.filter(step => { // Direct target match if (step.tags.includes(target)) return true; // Custom condition if (step.condition && step.condition(args)) return true; return false; }).sort((a, b) => a.priority - b.priority); } // Get all registered steps - useful for introspection getAllSteps(): ReadonlyArray { return [...this.steps]; } // Check if a target exists hasTarget(target: string): boolean { return this.steps.some(step => step.tags.includes(target)); } } // Build step definitions const pipeline = new BuildPipeline() .register({ name: "Fresh Install", priority: 3, tags: ['fresh', 'ci', 'setup'], runner: async () => { console.log(logger.info('Performing fresh install...')); execSync('yarn install --frozen-lockfile', { stdio: 'inherit' }); }, }) .register({ name: 'Build Time', priority: 5, tags: ['all', 'dev', 'binary', 'npm', 'types', 'lint', "fresh", 'ci', 'setup'], timing: true, runner: async () => { execSync('node ./scripts/build-time', { stdio: 'inherit' }); }, }) .register({ name: 'Required Files', priority: 10, tags: ['all', 'dev', 'binary', 'npm', "fresh", "ci", 'setup'], runner: async () => { ensureDirectoryExists('man'); ensureDirectoryExists('src/snippets'); if (isFileEmpty(`man/fish-lsp.1`) || isFileEmpty('src/snippets/helperCommands.json')) { execSync('yarn generate:man && yarn generate:snippets --write', { stdio: 'inherit' }); showBuildStats('man/fish-lsp.1', 'Man file'); showBuildStats('src/snippets/helperCommands.json', 'Helper Commands Snippets'); } console.log(logger.success(' Required files are up to date')); } }) .register({ name: 'Development', priority: 20, tags: ['all', 'dev', 'development', 'npm', "ci"], timing: true, runner: async (args) => { const config = buildConfigs.development; const buildOptions = createBuildOptions(config, args.production || args.minify, args.sourcemaps); await esbuild.build(buildOptions); }, postBuild: async () => { copyDevelopmentAssets(); }, }) .register({ name: 'TypeScript Declarations', priority: 25, tags: ['all', 'types', 'dev', 'npm', 'fresh', "ci"], timing: true, runner: async () => { generateTypeDeclarations(); }, postBuild: async () => { showBuildStats('dist/fish-lsp.d.ts', 'Type Declarations'); }, }) .register({ name: 'NPM Package', priority: 30, tags: ['all', 'npm', 'dev', 'fresh', "ci"], timing: true, runner: async (args) => { const config = buildConfigs.npm; ensureDirectoryExists('dist'); // Only override sourcemaps when explicitly changed from the CLI default const sourcemaps = args.sourcemaps !== 'optimized' ? args.sourcemaps : undefined; const buildOptions = createBuildOptions(config, args.production || args.minify, sourcemaps); await esbuild.build(buildOptions); }, postBuild: async () => { const config = buildConfigs.npm; if (config.outfile) { makeExecutable(config.outfile); showBuildStats(config.outfile, 'NPM Package Binary'); showDirectorySize('dist', 'dist/*'); } }, }) .register({ name: 'Universal Binary', priority: 40, tags: ['all', 'binary', 'dev'], timing: true, runner: async (args) => { const config = buildConfigs.binary; ensureDirectoryExists('bin'); const sourcemaps = args.sourcemaps !== 'optimized' ? args.sourcemaps : undefined; const buildOptions = createBuildOptions(config, args.production || args.minify, sourcemaps); await esbuild.build(buildOptions); }, postBuild: async () => { const config = buildConfigs.binary; if (config.outfile) { makeExecutable(config.outfile); showBuildStats(config.outfile, 'Universal Binary'); showDirectorySize('bin', 'bin/*'); } }, }) .register({ name: 'Lint Check', priority: 60, tags: ['lint', "ci"], timing: true, runner: async () => { try { execSync('yarn lint:fix', { stdio: 'inherit' }); } catch (error) { console.log(logger.warning('Lint check failed. Attempting to fix issues...')); execSync('yarn lint:check', { stdio: 'inherit' }); } }, }) .register({ name: 'Test Suite', priority: 70, tags: ['test', "ci"], timing: true, runner: async () => { execSync('yarn test:run', { stdio: 'inherit' }); }, }); // Export both the pipeline instance and the BuildPipeline class for extensibility export { BuildPipeline, pipeline, type BuildStep }; ================================================ FILE: scripts/esbuild/plugins.ts ================================================ // Build plugin factory for consistent configuration // ESBuild plugins - these packages don't provide TypeScript definitions import esbuild from 'esbuild'; import type { Plugin } from 'esbuild'; import { polyfillNode } from 'esbuild-plugin-polyfill-node'; import { nodeModulesPolyfillPlugin } from 'esbuild-plugins-node-modules-polyfill'; import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; import { colorize, colors, toRelativePath } from './colors'; import { writeFileSync, existsSync, readFileSync } from 'fs'; import path, { resolve } from 'path'; export interface PluginOptions { target: 'node' | 'browser'; typescript: boolean; polyfills: 'minimal' | 'full' | 'none'; embedAssets?: boolean; } export function createPlugins(options: PluginOptions): esbuild.Plugin[] { const plugins: esbuild.Plugin[] = []; // Handle generic .wasm imports plugins.push(createWasmPlugin()); // Simple embedded assets handler for wasm/package/man/build-time if (options.embedAssets) { plugins.push({ name: 'embedded-assets', setup(build) { const projectRoot = process.cwd(); const wasmFile = resolve(projectRoot, 'node_modules/@esdmr/tree-sitter-fish/tree-sitter-fish.wasm'); const coreWasmFile = resolve(projectRoot, 'node_modules/web-tree-sitter/tree-sitter.wasm'); const manFile = resolve(projectRoot, 'man', 'fish-lsp.1'); const buildTimeFile = resolve(projectRoot, 'out', 'build-time.json'); const pkgJsonFile = resolve(projectRoot, 'package.json'); build.onResolve({ filter: /^@embedded_assets\// }, (args) => ({ path: args.path.replace('@embedded_assets/', ''), namespace: 'embedded-asset', })); build.onResolve({ filter: /^web-tree-sitter\/tree-sitter\.wasm$/ }, () => ({ path: coreWasmFile, namespace: 'wasm-embedded', })); build.onResolve({ filter: /^@esdmr\/tree-sitter-fish\/tree-sitter-fish\.wasm$/ }, () => ({ path: wasmFile, namespace: 'wasm-embedded', })); const loadWasm = (filePath: string) => { if (!existsSync(filePath)) throw new Error(`Missing WASM asset: ${filePath}`); const content = readFileSync(filePath); return `export default "data:application/wasm;base64,${content.toString('base64')}"`; }; build.onLoad({ filter: /\.wasm$/, namespace: 'wasm-embedded' }, (args) => ({ contents: loadWasm(args.path), loader: 'js', })); build.onLoad({ filter: /.*/, namespace: 'embedded-asset' }, (args) => { const asset = args.path; if (asset === 'tree-sitter-fish.wasm') { return { contents: loadWasm(wasmFile), loader: 'js' }; } if (asset === 'tree-sitter.wasm') { return { contents: loadWasm(coreWasmFile), loader: 'js' }; } if (asset === 'package.json') { if (!existsSync(pkgJsonFile)) throw new Error('package.json not found for embedding'); const json = JSON.parse(readFileSync(pkgJsonFile, 'utf8')); return { contents: `export default ${JSON.stringify(json)};`, loader: 'js' }; } if (asset.startsWith('man/')) { const manPath = resolve(projectRoot, asset); if (!existsSync(manPath)) throw new Error(`Missing man asset: ${asset}`); const content = readFileSync(manPath, 'utf8'); return { contents: `export default ${JSON.stringify(content)};`, loader: 'js' }; } if (asset === 'out/build-time.json') { if (existsSync(buildTimeFile)) { const json = JSON.parse(readFileSync(buildTimeFile, 'utf8')); return { contents: `export default ${JSON.stringify(json)};`, loader: 'js' }; } const now = process.env.SOURCE_DATE_EPOCH ? new Date(parseInt(process.env.SOURCE_DATE_EPOCH) * 1000) : new Date(); const fallback = { date: now.toDateString(), timestamp: now.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium' }), isoTimestamp: now.toISOString(), unix: Math.floor(now.getTime() / 1000), version: process.env.npm_package_version || 'unknown', nodeVersion: process.version, reproducible: !!process.env.SOURCE_DATE_EPOCH, }; return { contents: `export default ${JSON.stringify(fallback)};`, loader: 'js' }; } return { contents: 'export default "";', loader: 'js' }; }); }, }); } // Note: Using native esbuild TypeScript support instead of external plugin for better performance // Polyfills based on target and level - only load when actually needed if (options.target === 'browser' && options.polyfills === 'full') { plugins.push( polyfillNode({ globals: { navigator: false, global: true, process: true, }, polyfills: { fs: true, path: true, stream: true, crypto: true, os: true, util: true, events: true, buffer: true, process: true, child_process: false, cluster: false, dgram: false, dns: false, http: false, https: false, net: false, tls: false, worker_threads: false, }, }), nodeModulesPolyfillPlugin() ); } else if (options.target === 'node' && options.polyfills === 'minimal') { plugins.push( NodeGlobalsPolyfillPlugin({ buffer: true, process: false, }) ); } return plugins; } export function createDefines(target: 'node' | 'browser' | string, production = false): Record { const defines: Record = { 'process.env.NODE_ENV': production ? '"production"' : '"development"', }; // Embed build-time for bundled versions try { const buildTimePath = resolve('out', 'build-time.json'); const buildTimeData = JSON.parse(readFileSync(buildTimePath, 'utf8')); defines['process.env.FISH_LSP_BUILD_TIME'] = `'${JSON.stringify(buildTimeData)}'`; } catch (error) { // If build-time.json doesn't exist, use current time as fallback const now = process.env.SOURCE_DATE_EPOCH ? new Date(parseInt(process.env.SOURCE_DATE_EPOCH) * 1000) : new Date(); const timestamp = now.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium' }); const fallbackBuildTime = { date: now.toDateString(), timestamp, isoTimestamp: now.toISOString(), unix: Math.floor(now.getTime() / 1000), version: process.env.npm_package_version || 'unknown', nodeVersion: process.version, reproducible: !!process.env.SOURCE_DATE_EPOCH }; defines['process.env.FISH_LSP_BUILD_TIME'] = `'${JSON.stringify(fallbackBuildTime)}'`; } // Mark as bundled for Node target (used by virtual filesystem) if (target === 'node') { defines['process.env.FISH_LSP_BUNDLED'] = '"true"'; } if (target === 'browser') { defines['global'] = 'globalThis'; defines['navigator'] = '{"language":"en-US"}'; } else { defines['global'] = 'globalThis'; defines['navigator'] = '{"language":"en-US"}'; } return defines; } /** * Plugin to optimize source maps by removing embedded source content * This reduces file size significantly while keeping source file references * @param preserveSourceContent - Keep source content for debugging (default: false for production, true for development) */ export function createSourceMapOptimizationPlugin(preserveSourceContent?: boolean): esbuild.Plugin { return { name: 'sourcemap-optimization', setup(build) { build.onEnd((result) => { if (!result.outputFiles && build.initialOptions.outfile && build.initialOptions.sourcemap) { const outfile = build.initialOptions.outfile; const sourcemapFile = outfile + '.map'; try { const sourcemapContent = readFileSync(sourcemapFile, 'utf8'); const originalSize = sourcemapContent.length; const sourcemap = JSON.parse(sourcemapContent); // Ensure the bundle has a sourcemap reference const bundleContent = readFileSync(outfile, 'utf8'); const sourcemapRef = `\n//# sourceMappingURL=${resolve(sourcemapFile).split('/').pop()}`; if (!bundleContent.includes('//# sourceMappingURL=')) { writeFileSync(outfile, bundleContent + sourcemapRef); } // Remove embedded source content to reduce file size // This keeps file references but removes the full source code if (preserveSourceContent) { console.log(` Source map: ${colorize(toRelativePath(sourcemapFile), colors.white)}`); console.log(` Size: ${colorize((originalSize/1024/1024).toFixed(1) + 'MB', colors.white)} (with source content for debugging)`); console.log(` Sources: ${colorize(sourcemap.sources.length + ' files', colors.white)}`); } else if (sourcemap.sourcesContent) { delete sourcemap.sourcesContent; const optimizedContent = JSON.stringify(sourcemap); writeFileSync(sourcemapFile, optimizedContent); const newSize = optimizedContent.length; const reduction = ((originalSize - newSize) / originalSize * 100).toFixed(1); console.log(` Optimized source map: ${colorize(toRelativePath(sourcemapFile), colors.white)}`); const reductionSize = colorize(`${reduction}% (${(originalSize/1024/1024).toFixed(1)}MB → ${(newSize/1024/1024).toFixed(1)}MB)`, colors.white); console.log(` Size reduction: ${reductionSize}`); console.log(` Sources: ${colorize(sourcemap.sources.length + ' files', colors.white)}`); } } catch (error) { // Silently ignore if source map doesn't exist or can't be processed } } }); }, }; } /** * Enhanced sourcemap plugin that filters sources to only include src/ files * and validates mappings bounds while preserving sourcesContent for debugging * @param options Configuration for the special sourcemap processing */ export function createSpecialSourceMapPlugin(options: { preserveOnlySrcContent?: boolean } = {}): esbuild.Plugin { return { name: 'special-sourcemap-optimization', setup(build) { build.onEnd((result) => { if (!result.outputFiles && build.initialOptions.outfile && build.initialOptions.sourcemap) { const outfile = build.initialOptions.outfile; const sourcemapFile = outfile + '.map'; try { const sourcemapContent = readFileSync(sourcemapFile, 'utf8'); const originalSize = sourcemapContent.length; const sourcemap = JSON.parse(sourcemapContent); // Ensure the bundle has a sourcemap reference const bundleContent = readFileSync(outfile, 'utf8'); const sourcemapRef = `\n//# sourceMappingURL=${resolve(sourcemapFile).split('/').pop()}`; if (!bundleContent.includes('//# sourceMappingURL=')) { writeFileSync(outfile, bundleContent + sourcemapRef); } if (options.preserveOnlySrcContent && sourcemap.sources && sourcemap.sourcesContent) { // Instead of filtering and breaking mappings, we'll selectively remove sourcesContent // for non-src files while keeping all sources for valid mappings const optimizedSourcesContent: (string | null)[] = []; let srcFileCount = 0; let removedSourcesSize = 0; sourcemap.sources.forEach((source: string, index: number) => { // Only preserve sourcesContent for TypeScript files from src/ directory // Remove content for node_modules, embedded assets, and other non-src files if ( source.includes('../src/') && source.endsWith('.ts') && !source.includes('node_modules') && !source.startsWith('embedded-asset:') && !source.includes('webpack://') ) { // Keep the source content for src files optimizedSourcesContent.push(sourcemap.sourcesContent[index] || null); srcFileCount++; } else { // Remove source content but keep the entry to maintain mapping indices const originalContent = sourcemap.sourcesContent[index] || ''; removedSourcesSize += originalContent.length; optimizedSourcesContent.push(null); } }); // Create optimized sourcemap with selective sourcesContent const optimizedSourcemap = { ...sourcemap, sourcesContent: optimizedSourcesContent }; console.log(` Special source map: ${colorize(toRelativePath(sourcemapFile), colors.white)}`); console.log(` Total sources: ${colorize(sourcemap.sources.length + ' files', colors.white)}`); console.log(` src/ files with content: ${colorize(srcFileCount + ' files', colors.white)}`); console.log(` Other sources (content removed): ${colorize((sourcemap.sources.length - srcFileCount) + ' files', colors.white)}`); if (srcFileCount > 0) { const optimizedContent = JSON.stringify(optimizedSourcemap); writeFileSync(sourcemapFile, optimizedContent); const newSize = optimizedContent.length; const reduction = originalSize > newSize ? ((originalSize - newSize) / originalSize * 100).toFixed(1) : '0'; console.log(` Size reduction: ${colorize(`${reduction}% (${(originalSize/1024/1024).toFixed(1)}MB → ${(newSize/1024/1024).toFixed(1)}MB)`, colors.white)}`); console.log(` Mappings preserved: ${colorize('All mappings intact', colors.white)}`); // Note: Shebang modification removed - use NODE_OPTIONS="--enable-source-maps" instead // to avoid process.argv parsing issues } else { console.log(` ${colorize('Warning: No src/ TypeScript files found in sourcemap', colors.white)}`); } } else { // Fallback to regular sourcemap optimization console.log(` Source map: ${colorize(toRelativePath(sourcemapFile), colors.white)}`); console.log(` Size: ${colorize((originalSize/1024/1024).toFixed(1) + 'MB', colors.white)} (preserved for debugging)`); // console.log(` Sources: ${colorize(sourcemap.sources.length + ' files', colors.white)}`); console.log(` Sources: ${colorize(sourcemap.sources.length + ' files', colors.white)}`); } } catch (error) { console.log(` ${colorize('Warning: Could not process sourcemap - ' + (error as Error).message, colors.white)}`); } } }); }, }; } /** * Loads .wasm files as base64 data URLs so they can be imported directly in bundles. */ export function createWasmPlugin(): Plugin { return { name: 'wasm-loader', setup(build) { build.onResolve({ filter: /\.wasm$/ }, (args) => { const isEmbedded = args.path.startsWith('@embedded_assets/'); const isRelative = args.path.startsWith('./') || args.path.startsWith('../'); const isAbsolute = path.isAbsolute(args.path); // Let other plugins handle embedded or bare module .wasm specifiers if (isEmbedded || (!isRelative && !isAbsolute)) { return; } return { path: resolve(args.resolveDir, args.path), namespace: 'wasm-inline', }; }); build.onLoad({ filter: /\.wasm$/, namespace: 'wasm-inline' }, (args) => { try { const content = readFileSync(args.path); const base64 = content.toString('base64'); return { contents: `export default "data:application/wasm;base64,${base64}";`, loader: 'js', }; } catch { return { contents: 'export default "";', loader: 'js', }; } }); }, }; } ================================================ FILE: scripts/esbuild/types.ts ================================================ // Build target type definitions for fish-lsp esbuild system /** * Individual build configuration targets that map to actual build configs */ export type BuildConfigTarget = 'binary' | 'development' | 'npm'; /** * Meta targets that control the build process behavior */ export type MetaTarget = 'all' | 'types' | 'library' | 'test' | 'ci' | 'fresh' | 'setup'; /** * All possible build targets that can be passed to the build system */ export type BuildTarget = BuildConfigTarget | MetaTarget; /** * Watch/build mode used by file-watcher and CLI --mode flag */ export type WatchMode = 'dev' | 'binary' | 'npm' | 'types' | 'all' | 'lint' | 'test' | 'ci' | 'fresh' | 'setup'; /** * Sourcemap generation modes */ export type SourcemapMode = 'optimized' | 'extended' | 'none' | 'special'; // ============================================================================ // TargetInfo — single source of truth for all target metadata // ============================================================================ export interface TargetInfo { readonly index: number; readonly name: string; readonly altNames: readonly string[]; readonly label: string; readonly description: string; readonly keys: readonly string[]; readonly type: 'meta' | 'build'; readonly command: readonly string[]; /** Color name used in help/mode menus (maps to String prototype color) */ readonly helpColor: string; } export namespace TargetInfo { let _idx = 0; export function create( name: string, label: string, description: string, command: string[], helpColor?: string, keys?: string[], altNames?: string[], ): TargetInfo { _idx++; const buildNames: string[] = ['binary', 'development', 'npm']; const isBuild = buildNames.includes(name) || (altNames?.some(a => buildNames.includes(a)) ?? false); return { index: _idx, name, altNames: altNames ?? [], label, description, keys: [String(_idx), ...(keys ?? [])], type: isBuild ? 'build' : 'meta', command, helpColor: helpColor ?? 'dim', }; } /** Get the single-letter keyboard shortcut (e.g. 'd', 'n', 'b') */ export function letterKey(info: TargetInfo): string | undefined { return info.keys.find(k => /^[a-z]$/i.test(k)); } /** Quick mode summary string: "1:full, 2:npm, 3:lint, ..." */ export function quickModeSummary(items: readonly TargetInfo[]): string { return items.map(t => `${t.index}:${t.label.toLowerCase()}`).join(', '); } /** Format a help entry with combined index+key: "[4|B]", color name, and description */ export function helpEntry(info: TargetInfo): { key: string; color: string; text: string } | undefined { const letter = letterKey(info); if (!letter) return undefined; return { key: `[${info.index}|${letter.toUpperCase()}]`, color: info.helpColor, text: info.description }; } } /** * All targets with their metadata. Order determines the 1-based index * and the numeric keyboard shortcut in watch mode. */ export const targets: readonly TargetInfo[] = [ TargetInfo.create('dev', 'Full', 'Full Project (yarn build)', ['dev'], 'cyan', ['d'], ['development']), TargetInfo.create('npm', 'NPM', 'NPM Build (yarn dev --npm)', ['dev', '--npm'], 'yellow', ['n']), TargetInfo.create('lint', 'Lint', 'Lint Fix (yarn lint:fix)', ['lint:fix'], 'magenta', ['l']), TargetInfo.create('binary', 'Binary', 'Binary Build (yarn dev --binary)', ['dev', '--binary'], 'blue', ['b'], ['bin']), TargetInfo.create('test', 'Test', 'Test Run (yarn test)', ['test:run'], 'green', ['t']), TargetInfo.create('types', 'Types', 'Types Build (yarn dev --types)', ['dev', '--types'], 'white', ['y']), TargetInfo.create('ci', 'CI/CD', 'CI/CD Test (yarn dev --ci)', ['dev', '--ci'], 'magenta', ['c']), TargetInfo.create('all', 'All Targets', 'All Targets (yarn dev --all)', ['dev', '--all']), TargetInfo.create('fresh', 'Fresh', 'Fresh Install (yarn dev --fresh)', ['dev', '--fresh']), TargetInfo.create('setup', 'Setup', 'Setup (yarn dev --setup)', ['dev', '--setup']), ]; /** Targets that have keyboard shortcuts in watch mode (index 1-7) */ export const keyboardTargets: readonly TargetInfo[] = targets.filter(t => t.keys.length > 1); // ============================================================================ // Derived constants // ============================================================================ export const VALID_WATCH_MODES: readonly string[] = targets.map(t => t.name); export const VALID_SOURCEMAP_MODES: readonly SourcemapMode[] = ['optimized', 'extended', 'none', 'special']; // ============================================================================ // Lookup helpers // ============================================================================ /** Find a target by name, alt name, or keyboard key */ export function findTarget(nameOrKey: string): TargetInfo | undefined { return targets.find(t => t.name === nameOrKey || t.altNames.includes(nameOrKey) || t.keys.includes(nameOrKey) ); } /** Get target by its primary name (throws if not found) */ export function getTarget(name: string): TargetInfo { const target = targets.find(t => t.name === name); if (!target) throw new Error(`Unknown target: ${name}`); return target; } ================================================ FILE: scripts/esbuild/utils.ts ================================================ // Build utility functions import fs from 'fs-extra'; import { existsSync, statSync, unlinkSync } from 'fs'; import { execSync, spawnSync } from 'child_process'; import { logger, toRelativePath } from './colors'; export function copyDevelopmentAssets(): void { if (existsSync('src/snippets')) { fs.copySync('src/snippets', 'out/snippets'); console.log(logger.copied('src/snippets', 'out/snippets')); } } export function ensureDirectoryExists(dirPath: string): void { if (existsSync(dirPath)) return; fs.mkdirSync(dirPath, { recursive: true }); } export function formatBytes(bytes: number): string { return (bytes / 1024 / 1024).toFixed(2) + ' MB'; } export function makeExecutable(filePath: string): void { try { execSync(`chmod +x "${filePath}"`); console.log(logger.executable(filePath)); } catch (error) { logger.warn(`Could not make executable: ${filePath}`); } } export function showBuildStats(filePath: string, label = 'Bundle'): void { if (existsSync(filePath)) { const size = statSync(filePath).size; console.log(logger.complete(label)); console.log(logger.info(` ${label}: ${logger.dim(toRelativePath(filePath))}`)); console.log(logger.size(label, formatBytes(size))); } } export function showDirectorySize(dirPath: string, label?: string): void { if (!existsSync(dirPath)) { return; } const files = fs.readdirSync(dirPath); let totalSize = 0; for (const file of files) { const filePath = `${dirPath}/${file}`; const stats = statSync(filePath); if (stats.isFile()) { totalSize += stats.size; } } const displayLabel = label || dirPath; console.log(logger.size(`${displayLabel} total`, formatBytes(totalSize))); } export function isFileEmpty(filePath: string): boolean { if (!existsSync(filePath)) { return true; } const stats = statSync(filePath); return stats.size === 0; } export function generateTypeDeclarations(): void { console.log(logger.info(' Generating TypeScript declarations...')); try { execSync('mkdir -p dist'); // Step 1: Create tsconfig used for declaration emit const tsconfigContent = JSON.stringify({ "extends": ["@tsconfig/node22/tsconfig.json"], "compilerOptions": { "declaration": true, "emitDeclarationOnly": true, "outDir": "temp-types", // Remove rootDir to avoid conflicts with path mapping "target": "es2018", "lib": ["es2018", "es2019", "es2020", "es2021", "es2022", "es2023", "dom"], "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": false, "skipLibCheck": true, "skipDefaultLibCheck": true, "resolveJsonModule": true, "allowJs": false, "types": ["node", "vscode-languageserver"], "baseUrl": ".", // Suppress some strict checks for cleaner output "noImplicitAny": false, "noImplicitReturns": false, "noImplicitThis": false }, "include": [ "src/**/*.ts", "src/types/embedded-assets.d.ts" ], "exclude": [ "node_modules/**/*", "tests/**/*", "**/*.test.ts", "**/vitest/**/*", "node_modules/vitest/**/*" ] }); fs.writeFileSync('tsconfig.types.json', tsconfigContent); // Step 2.5: Create debug tsconfig for dts-bundle-generator const debugTsconfigContent = JSON.stringify({ "extends": ["@tsconfig/node22/tsconfig.json"], "compilerOptions": { "declaration": true, "emitDeclarationOnly": true, "outDir": "temp-types", "target": "es2018", "lib": ["es2018", "es2019", "es2020", "es2021", "es2022", "es2023", "dom"], "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": false, "skipLibCheck": true, "skipDefaultLibCheck": true, "resolveJsonModule": true, "allowJs": false, "types": ["node", "vscode-languageserver"], "baseUrl": ".", "noImplicitAny": false, "noImplicitReturns": false, "noImplicitThis": false }, "include": [ "src/**/*.ts", "src/types/embedded-assets.d.ts" ], "exclude": [ "node_modules/**/*", "tests/**/*", "**/*.test.ts", "**/vitest/**/*", "node_modules/vitest/**/*" ] }); fs.writeFileSync('tsconfig.debug.json', debugTsconfigContent); // Step 3: Generate .d.ts files with TypeScript compiler console.log(logger.info(' Compiling TypeScript declarations...')); execSync('node_modules/typescript/bin/tsc -p tsconfig.types.json', { stdio: 'inherit' }); // Step 4: Bundle all declarations with dts-bundle-generator console.log(logger.info(' Bundling type declarations...')); const dtsConfig = { "compilationOptions": { "preferredConfigPath": "./tsconfig.debug.json", "followSymlinks": false }, "entries": [ { "filePath": "./temp-types/src/main.d.ts", "outFile": "./dist/fish-lsp.d.ts", "noCheck": true, "output": { "inlineDeclareExternals": true, "sortNodes": true, "exportReferencedTypes": false, "respectPreserveConstEnum": true, }, "libraries": { "allowedTypesLibraries": ["web-tree-sitter", "vscode-languageserver", "vscode-languageserver-textdocument", "node"], "importedLibraries": ["web-tree-sitter", "vscode-languageserver", "vscode-languageserver-textdocument"] } } ] }; fs.writeFileSync('dts-bundle.config.json', JSON.stringify(dtsConfig, null, 2)); execSync('yarn dts-bundle-generator --silent --config dts-bundle.config.json --external-inlines=web-tree-sitter --external-types=web-tree-sitter --disable-symlinks-following', { stdio: 'ignore' }); console.log(logger.generated('Successfully generated bundled type declarations')); } catch (error) { console.error(logger.error('Type generation failed:'), error); throw error; } finally { // Clean up temp files and directories console.log(logger.info(' Cleaning up temporary files...')); try { unlinkSync('tsconfig.types.json'); } catch { } try { unlinkSync('tsconfig.debug.json'); } catch { } try { unlinkSync('dts-bundle.config.json'); } catch { } try { execSync('rm -rf temp-types', { stdio: 'pipe' }); } catch { } } } ================================================ FILE: scripts/fish/continue-or-exit.fish ================================================ #!/usr/bin/env fish set -l DIR (status current-filename | path resolve | path dirname) source "$DIR/pretty-print.fish" ### Example: ### ```fish ### >_ continue_or_exit -q || echo $status` ### ``` function continue_or_exit --description 'reusable fish prompt utility for shell script continuation' set -l original_argv $argv argparse h/help q/quiet Q/quit no-empty-accept no-retry prepend-prompt= time-in-prompt prompt-str= quiet-prompt other-opts=+ no-quit-opts -- $argv or return if set -q _flag_help echo "Usage: continue_or_exit [-h|--help] [-q|--quiet] [--quit] [--no-empty-accept] [--no-retry] [--other-opts='OPT_1,OPT_2,...'] [--no-quit-opts]" echo '' echo 'Ask user to continue or exit.' echo 'If the user input is not valid, it will ask again (when --no-retry is not given).' echo '' echo 'Options:' echo ' -h, --help Show this help message and exit.' echo ' -q, --quiet Do not print any output message.' echo ' -Q,--quit Add separate quit Q/q option to exit w/ status 2' echo ' Normally the Q/q option will be treated the same as n/N' echo ' --time-in-prompt Add time to the prompt string.' echo ' --prepend-prompt STRING Add text to the start of the prompt string.' echo ' --prompt-str STRING Customize the prompt string.' echo ' --quiet-prompt Do not print a prompt string or any output message' echo ' equivalent to `continue_or_exit -q --prompt-str=\'\'`' echo ' --no-empty-accept Do not accept empty input.' echo ' --no-retry Do not ask again if the input is not valid.' echo ' --other-opts 1,2,3 Add other acceptable options to the prompt.' echo ' --no-quit-opts Do not add quit options, `Q/q`, to exit prompt list.' echo '' echo 'Examples:' echo " >_ continue_or_exit -q || echo \$status" echo '' echo " >_ set -l idx 1" echo " >_ while continue_or_exit" echo " >_ echo 'idx: \$idx'" echo " >_ set idx (math \$idx+1)" echo " >_ end" echo '' echo " >_ set -l output (continue_or_exit --other-opts 'a,b,c' --prepend-prompt '(a,b,c)' --no-quit-opts)" echo " >_ # input your choice, a selection of --other-opt will be stored in output" echo " >_ set --show output" echo " >_ switch \$output" echo " >_ case a" echo " >_ echo 'You selected a'" echo " >_ case b" echo " >_ echo 'You selected b'" echo " >_ case c" echo " >_ echo 'You selected c'" echo " >_ case *" echo " >_ echo 'You selected something else'" echo " >_ end" exit 0 end set -l yes_options Y y '' set -l no_options N n set -l quit_options Q q set -l retry_options '*' set -l other_options '' if set -q _flag_no_quit_opts set -a retry_options $quit_opts set -e quit_options end if set -q _flag_no_empty_accept set yes_options Y y set --append retry_options '' end # if set -q _flag_quiet_prompt && set a if set -q _flag_quiet_prompt set _flag_quiet 1 set _flag_prompt_str '' end if set -q -l _flag_other_opts if test (count $_flag_other_opts) -gt 1 set other_options $_flag_other_opts else if string match -raq ',' -- $_flag_other_opts set other_options (string split ',' -n -- $_flag_other_opts) else if string match -raq ' ' -- $_flag_other_opts set other_options (string split ' ' -n -- $_flag_other_opts) end if test -n "$other_options" && test (count $other_options) -gt 0 set yes_options $yes_options $other_options end end not set -q _flag_no_quit_opts && set -q _flag_quit && set --append no_options Q q if set -q _flag_prompt_str set prompt "$_flag_prompt_str" else set prompt (print_text_with_color '--bold white' 'Continue?') "$(print_text_with_color 'brcyan --italic' ' [Y/n] ')" end if set -q _flag_prepend_prompt set prompt (print_text_with_color 'brblue --italic' "$_flag_prepend_prompt") $prompt end if set -q _flag_time_in_prompt set prompt (print_text_with_color '--background normal yellow' "(TIME: $(date +%T)) ") $prompt end function _abort_read --inherit-variable answer --inherit-variable _flag_quiet set -q _flag_quiet && return 0 print_text_with_color brred Aborted return 0 end read --nchars 1 --prompt-str "$prompt" --local answer or _abort_read && return 1 set -gx CONTINUE_OR_EXIT_ANSWER $answer switch "$answer" case $yes_options if contains -- "$answer" $other_options not set -q _flag_quit && echo $answer return 0 end not set -q _flag_quiet && print_text_with_color green "Continuing...\n" return 0 case $no_options not set -q _flag_quiet && print_text_with_color red "Exiting...\n" return 1 case $quit_options set -q _flag_quit && set msg_args magenta "Quitting...\n" || set msg_args red "Exiting...\n" not set -q _flag_quiet && print_text_with_color $msg_args[1] $msg_args[2] set -q _flag_quit && return 2 or return 1 case $retry_options set -q _flag_no_retry && set msg_args blue "Invalid input: '$answer'\n" || set msg_args red "Invalid input: '$answer'\nPlease try again.\n" not set -q _flag_quiet && print_text_with_color $msg_args[1] $msg_args[2] set -q _flag_no_retry && return 1 continue_or_exit $original_argv end end function print_text_with_color --argument-names color text --description 'Print with color' echo $color | read --delimiter=' ' -a fixed_color [ (count $fixed_color) -eq 1 ] && set fixed_color --bold $fixed_color set_color $fixed_color echo -ne "$text" set_color normal end ================================================ FILE: scripts/fish/pretty-print.fish ================================================ function reset_color set_color normal end set -gx NORMAL (set_color normal) set -gx GREEN (reset_color && set_color green) set -gx BLUE (reset_color && set_color blue) set -gx RED (reset_color && set_color red) set -gx YELLOW (reset_color && set_color yellow) set -gx CYAN (reset_color && set_color cyan) set -gx MAGENTA (reset_color && set_color magenta) set -gx WHITE (reset_color && set_color white) set -gx BLACK (reset_color && set_color black) set -gx BOLD (set_color --bold) set -gx REVERSE (set_color --reverse) set -gx UNDERLINE (set_color --underline) set -gx ITALIC (set_color --italics) set -gx ITALICS (set_color --italics) set -gx DIM (set_color --dim) set -gx BRIGHT_GREEN (set_color brgreen) set -gx BRIGHT_BLUE (set_color brblue) set -gx BRIGHT_RED (set_color brred) set -gx BRIGHT_YELLOW (set_color bryellow) set -gx BRIGHT_CYAN (set_color brcyan) set -gx BRIGHT_MAGENTA (set_color brmagenta) set -gx BRIGHT_WHITE (set_color brwhite) set -gx BRIGHT_BLACK (set_color brblack) set -gx BOLD_GREEN (reset_color && set_color green --bold) set -gx BOLD_BLUE (reset_color && set_color blue --bold) set -gx BOLD_RED (reset_color && set_color red --bold) set -gx BOLD_YELLOW (reset_color && set_color yellow --bold) set -gx BOLD_CYAN (reset_color && set_color cyan --bold) set -gx BOLD_MAGENTA (reset_color && set_color magenta --bold) set -gx BOLD_WHITE (reset_color && set_color white --bold) set -gx BOLD_BLACK (reset_color && set_color black --bold) set -gx UNDERLINE_GREEN (reset_color && set_color green --underline) set -gx UNDERLINE_BLUE (reset_color && set_color blue --underline) set -gx UNDERLINE_RED (reset_color && set_color red --underline) set -gx UNDERLINE_YELLOW (reset_color && set_color yellow --underline) set -gx UNDERLINE_CYAN (reset_color && set_color cyan --underline) set -gx UNDERLINE_MAGENTA (reset_color && set_color magenta --underline) set -gx UNDERLINE_WHITE (reset_color && set_color white --underline) set -gx UNDERLINE_BLACK (reset_color && set_color black --underline) set -gx BG_GREEN (set_color --background green) set -gx BG_BLUE (set_color --background blue) set -gx BG_RED (set_color --background red) set -gx BG_YELLOW (set_color --background yellow) set -gx BG_CYAN (set_color --background cyan) set -gx BG_MAGENTA (set_color --background magenta) set -gx BG_WHITE (set_color --background white) set -gx BG_BLACK (set_color --background black) function icon_check -d 'Check icon' printf %s ' ' end function icon_x -d 'Cross icon' printf %s ' ' end function icon_warning -d 'Warning icon' printf %s ' ' end function icon_info -d 'Information icon' printf %s ' ' end function icon_question -d 'Question icon' printf %s ' ' end function icon_folder -d 'Folder icon' printf %s ' ' end function icon_file -d 'File icon' printf %s ' ' end # helpers # @fish-lsp-disable 4004 function print_separator -d '\\
' string repeat --count=80 -- '─' end function print_success -d 'Print success message' echo $BOLD_GREEN"$(icon_check)SUCCESS: $GREEN$argv"$NORMAL end function print_failure -d 'Print failure message' echo $BOLD_RED"$(icon_x)FAILURE: $RED$argv"$NORMAL >&2 end function print_error -d 'Print error message' echo $BOLD_RED"$(icon_x)ERROR: $RED$argv"$NORMAL >&2 end function log_info -d 'Print success message' -a icon title message set result if test -n "$icon" set -a result (string pad --width 5 --right --char ' ' -- " $WHITE$icon$NORMAL") end if test -n "$title" set -a result (string pad --width 10 --right --char ' ' -- "$BOLD_GREEN$title$NORMAL") end if test -n "$message" set -a result "$CYAN$message$NORMAL" end string join ' ' -- $result end function log_warning -d 'Print warning message' -a icon title message set -l result if test -n "$icon" set -a result (string pad --width 5 --right --char ' ' -- " $YELLOW$icon$NORMAL") end if test -n "$title" set -a result (string pad --width 10 --right --char ' ' -- "$BOLD_YELLOW$title$NORMAL") end if test -n "$message" set -a result "$YELLOW$message$NORMAL" end string join ' ' -- $result end function log_error -d 'Print error message' -a icon title message set -l result if test -n "$icon" set -a result (string pad --width 5 --right --char ' ' -- " $WHITE$icon$NORMAL") end if test -n "$title" set -a result (string pad --width 10 --right --char ' ' -- "$BOLD_RED$title$NORMAL") end if test -n "$message" set -a result "$RED$message$NORMAL" end string join ' ' -- $result end function success -d 'Print success message' set icon (icon_check) log_info "$icon" '[OK]' "$argv" end function fail -d 'Print error message and exit' set icon (icon_x) log_error "$icon" '[ERROR]' "$argv" exit 1 end # A general logging function with various options to customize the output # # USAGE: # log_msg [OPTIONS] [TITLE] MESSAGE # # EXAMPLES: # >_ log_msg --info "This is an informational message" # `  [INFO] This is an informational message` # # >_ log_msg --fail "Low disk space" --exit # `  [WARNING] Low disk space` # exits with status 1 # function log_msg -d 'Print log message' argparse --ignore-unknown \ -x w,e,i,d \ -x success,failure \ w/warning e/error i/info d/debug \ 'icon=?' 't/title=?' 'm/message=?' \ 'theme=?' date \ pass passed success fail failed failure exit \ h/help -- $argv or return 1 if set -ql _flag_pass || set -ql _flag_passed || set -ql _flag_success set -f _flag_success 1 end if set -ql _flag_fail || set -ql _flag_failed || set -ql _flag_failure set -f _flag_failure 1 end if set -q _flag_help echo \ 'Usage: log_msg [OPTIONS] [TITLE] MESSAGE Options: -w, --warning Set log level to WARNING -e, --error Set log level to ERROR -i, --info Set log level to INFO -d, --debug Set log level to DEBUG --icon ICON Specify a custom icon --title TITLE Specify a custom title --message MESSAGE Specify a custom message --theme THEME Specify a theme --date Prepend the current date and time to the message --success, --pass, --passed Print message in passed style --failure, --fail, --failed Print message in failed style --exit Exit after printing the message --help Show this help message Arguments: TITLE The title of the log message (optional if --title is used) MESSAGE The log message content Examples: >_ log_msg "TITLE" "MESSAGE" [TITLE] MESSAGE >_ log_msg --success "Operation completed successfully"  [OK] Operation completed successfully >_ log_msg --info "This is an informational message"  [INFO] This is an informational message >_ log_msg --warning "Low disk space"  [WARNING] Low disk space >_ log_msg --fail --error "Failed to connect to server"  [ERROR] Failed to connect to server # Exits with status 1 >_ log_msg --date --debug "Debugging application"  [DEBUG] [2024-06-01 12:34:56] Debugging application' return 0 end set icon '' set title '' set message '' set theme '' set remaining_args (count $argv) if set -q _flag_warning set icon (icon_warning) set title '[WARNING]' set theme "$YELLOW" else if set -q _flag_error set icon (icon_x) set title '[ERROR]' set theme "$RED" else if set -q _flag_info set icon (icon_info) set title '[INFO]' set theme "$BLUE" else if set -q _flag_debug set icon (icon_question) set title '[DEBUG]' set theme "$MAGENTA" else if set -q _flag_success set icon (icon_check) set title '[SUCCESS]' set theme "$GREEN" else if set -q _flag_failure set icon (icon_x) set title '[FAILURE]' set theme "$RED" end if set -q _flag_icon switch $_flag_icon case 'check' set icon (icon_check) case 'x' set icon (icon_x) case 'warning' set icon (icon_warning) case 'info' set icon (icon_info) case 'question' set icon (icon_question) case 'folder' set icon (icon_folder) case 'file' set icon (icon_file) case '*' set icon $_flag_icon end end test -z "$icon" && set icon (icon_info) set -q _flag_title && set title $_flag_title set -q _flag_message && set message $_flag_message set -q _flag_theme && set theme $NORMAL$_flag_theme if test $remaining_args -eq 2 if test -z "$title" set title $argv[1] set message $argv[2] else set message (string join ':' -n -- $(string upper -- $argv[1]) $argv[2]) end else if test $remaining_args -eq 1 set message $argv[1] end if set -q _flag_date set message (string join ' ' -- \ (echo "$message$NORMAL" | string pad --width 35 --right --char ' ') \ "$WHITE$BG_BLACK$REVERSE $(date '+%Y-%m-%d %H:%M:%S') $NORMAL" ) end if test -n "$title" set title (string trim -- $title | string upper) if string match -rvq -- '\[.*\]' "$title" set title "[$(string upper -- $title)]" end end string join -n ' ' -- $(echo " $NORMAL$theme$BG_BLACK$REVERSE $icon $NORMAL$theme" | string pad --width 7 --right --char " " ) \ (string pad --width 5 -- ' ') \ (string pad --width 15 --right --char ' ' -- "$theme$BOLD$title$NORMAL") \ "$NORMAL$theme$message$NORMAL" set -q _flag_exit && exit 1 end ================================================ FILE: scripts/fish/utils.fish ================================================ #!/usr/bin/env fish set -l DIR (status current-filename | path resolve | path dirname) source "$DIR/continue-or-exit.fish" source "$DIR/pretty-print.fish" set -g exec_count 1 # wrapper to prevent evaling command when --dry-run, # added # otherwise log the description and execute the command function exec_cmd -a description command -d 'Executes a command with logging and dry-run support.' argparse --stop-nonopt --ignore-unknown i/interactive n/numbered -- $argv[3..] or return 1 if $DRY_RUN set msg "Would execute: $BLUE>_$NORMAL `$BOLD_WHITE$command$NORMAL`" set -ql _flag_numbered and set msg "$BOLD$REVERSE STEP $exec_count $NORMAL$CYAN Would execute: $BLUE>_$NORMAL `$BOLD_WHITE$command$NORMAL`" and set -g exec_count (math $exec_count+1) log_info '󰜎' '[DRY RUN]' "$msg" return 0 end if set -ql _flag_numbered set -f description "$CYAN$REVERSE STEP $exec_count $NORMAL$CYAN $description" set -g exec_count (math $exec_count+1) end log_info '' '[EXEC]' "$description" set should_confirm (set -q _flag_i || $INTERACTIVE; and echo 'true' || echo 'false') $should_confirm && $SKIP_CONFIRM && set should_confirm 'false' if $should_confirm confirm "Execute: `$BOLD_WHITE$command$NORMAL`" or fail "Aborted by user" end eval $command end # wrapper to format confirmation prompts and handle dry-run or skip-confirm # if exit status is 0, then the user confirmed, otherwise it failed function confirm -a message -d 'Prompts the user for confirmation before proceeding.' if $SKIP_CONFIRM; or $DRY_RUN $DRY_RUN && log_info '󰜎' '[DRY RUN]' "Would prompt: $BLUE$message$NORMAL" return 0 end continue_or_exit --time-in-prompt --prepend-prompt="$BLUE$message$NORMAL" --prompt-str="$BOLD_WHITE [Y/n]? $NORMAL" --no-empty-accept --quiet 2>/dev/null or return 1 return $status end # wrapper to format logging when the script should halt execution and exit early function fail -a message -d 'Logs an error message and exits with status 1.' log_error '❌' '[ERROR]' $message exit 1 end # outputs text for the following: latest npm preminor version, git remote tags, and local git tags function check_exists -a type item -d 'Checks if an item exists in the specified type (npm, git-remote, git-local).' switch $type case npm npm show $item version &>/dev/null case git-remote git ls-remote --tags origin $item | grep -q "refs/tags/$item\$" case git-local git tag -l $item | grep -q "^$item\$" end end function check_and_fix_tag -d 'Checks if both the npm package and git tags exist for the current package and version.' \ --inherit-variable package_name \ --inherit-variable package_version \ --inherit-variable git_tag check_exists npm "$package_name@$package_version"; and fail "Version $package_version already on npm" check_exists git-remote $git_tag; and fail "Tag $git_tag already on remote" # Handle local tag conflict if check_exists git-local $git_tag log_warning '⚠️' '[WARNING]' "Local git tag $git_tag exists" confirm "Delete local git tag $git_tag"; or fail "Aborted by user" exec_cmd "Delete local git tag $git_tag" "git tag -d $git_tag" --interactive; or fail "Failed to delete local git tag" end # log_info '✅' '[CHECK]' "No conflicts found:$BLUE  $package_name@$package_version$CYAN |$BLUE  $git_tag$NORMAL" log_info '✅' '[CHECK]' "NO EXISTING VERSION CONFLICTS FOUND!$NORMAL" end function get_npm_pkg_name -d 'Gets the package name from npm.' npm pkg get name 2>/dev/null | string unescape end function get_npm_pkg_version -d 'Gets the package version from npm.' npm pkg get version 2>/dev/null | string unescape end function get_npm_url -d 'Constructs the npm package URL for the current package and version.' echo "https://www.npmjs.com/package/$(get_npm_pkg_name)/v/$(get_npm_pkg_version)" end # outputs the next preminor version based on the latest npm preminor version function get_next_npm_preminor_version -d 'echo the next preminor version based on the latest npm preminor version' set latest (npm show "fish-lsp@preminor" version 2>/dev/null) set -l parts (string split '.' $latest) set next_version "$parts[1].$parts[2].$parts[3]."(math $parts[4] + 1) echo $next_version end ================================================ FILE: scripts/fish-commands-scrapper.ts ================================================ /* eslint-disable no-console */ import { JSDOM } from 'jsdom'; import fetch from 'node-fetch'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; interface FishCommand { name: string; description: string; } interface FishFunctionDefinition { name: string; file: string; flags: string[]; description?: string; } // Check command line arguments const args = process.argv.slice(2); // Check if --help flag is provided if (args.includes('--help') || args.includes('-h')) { printHelp(); process.exit(0); } // Check if --completions flag is provided if (args.includes('--completions') || args.includes('-c')) { printCompletions(); process.exit(0); } const datasetConfig = { commands: { outputFile: 'helperCommands.json', }, functions: { outputFile: 'functions.json', }, 'special-variables': { outputFile: 'specialFishVariables.json', }, 'env-variables': { outputFile: 'envVariables.json', }, } as const; type DatasetType = keyof typeof datasetConfig; const writeOutput = args.includes('--write'); const diffOutput = args.includes('--diff') || args.includes('-d'); // Validate flag combinations if (diffOutput && writeOutput) { console.error('Error: --diff and --write flags cannot be used together'); process.exit(1); } const hasShowArg = args.some(arg => arg.startsWith('--show=')); const showArgsArray: DatasetType[] = args .filter(arg => arg.startsWith('--show=')) .flatMap(arg => arg.split('=')[1]!.split(',')) .map(entry => entry.trim()) .filter((entry): entry is DatasetType => entry.length > 0 && entry in datasetConfig); const showArgs: Record = showArgsArray.reduce((acc, curr) => { acc[curr as keyof typeof datasetConfig].seen = true; return acc; }, { commands: { seen: false }, functions: { seen: false }, 'special-variables': { seen: false }, 'env-variables': { seen: false }, } as Record); function printHelp() { console.log(` Fish Commands and Variables Scraper =================================== A tool that scrapes commands and special variables from the Fish shell documentation and outputs them in JSON format. Usage: yarn tsx ./scripts/fish-commands-scraper.ts [options] Options: -h, --help Show this help message and exit -c, --completions Output Fish completions to stdout -d, --diff Show diff of new data vs existing snippets/*.json files (Cannot be used with --write) --show=commands|special-variables|env-variables|functions Output the requested data to stdout (default dataset is 'commands') --write Save the generated JSON to ./src/snippets/.json (Requires at least one --show flag; defaults to commands when omitted) Examples: # Output commands to stdout (Default behavior) yarn tsx scripts/fish-commands-scraper.ts # Write commands to file yarn tsx scripts/fish-commands-scraper.ts --write --show=commands # Show diff before writing yarn tsx scripts/fish-commands-scraper.ts --diff --show=commands # Generate and save Fish completions to file yarn tsx scripts/fish-commands-scraper.ts --completions > ~/.config/fish/completions/fish-commands-scrapper.fish # Source completions dynamically in current shell (using psub for process substitution) source (yarn -s tsx scripts/fish-commands-scraper.ts --completions | psub) # Use with yarn run (--silent/-s flag suppresses yarn's output) source (yarn -s run generate:snippets --completions | psub) `); } function printCompletions() { const completionScript = `# Fish completion for fish-commands-scrapper # This file can be saved to ~/.config/fish/completions/fish-commands-scrapper.fish # Or sourced directly: source (yarn tsx scripts/fish-commands-scrapper.ts --completions | psub) function __fish_fcs_show_state set -l token (commandline -ct) set -l prev (commandline -pt) if string match -q -- '--show=*' -- $token set token (string replace -r '^.*--show=' '' -- $token) else if test "$prev" = '--show' set token '' else return 1 end set -l trailing_comma (string match -q -- ',$' "$token"; and echo 1) set -l entries set -l current '' if test -n "$token" if string match -q -- '*,*' "$token" # Has comma(s), split and process set entries (string split ',' -- $token) if test "$trailing_comma" = '1' set current '' else set current $entries[-1] set -e entries[-1] end else # No comma, entire token is the current partial entry set current $token end end set -l joined_entries (string join ',' $entries) printf '%s\\n%s\\n' "$joined_entries" "$current" end function __fish_fcs_show_candidates set -l state (__fish_fcs_show_state); or return 0 set -l used if test -n "$state[1]" set used (string split ',' -- $state[1]) end set -l current $state[2] # Build prefix for completions (the already-entered values) set -l prefix '' if test -n "$state[1]" set prefix "$state[1]," end set -l datasets commands special-variables env-variables functions for ds in $datasets if test -n "$used" if contains -- $ds $used continue end end if test -n "$current" if not string match -q -- "$current*" $ds continue end end # Output with prefix so it replaces the whole value echo "$prefix$ds" end end function __fish_fcs_in_show_context __fish_fcs_show_state >/dev/null end # Direct script completions # Inline completion for --show=value,value,... complete -c fish-commands-scrapper \\ -n 'string match -q -- "--show=*" (commandline -ct)' \\ -f \\ -a '(__fish_fcs_show_candidates)' \\ -d 'Dataset' # --show flag (only if not already present) complete -c fish-commands-scrapper \\ -n 'not string match -q -- "*--show=*" (commandline -poc)' \\ -l show -x \\ -a '(__fish_fcs_show_candidates)' \\ -d 'Dataset' # --write flag (only if not already present and not --diff) complete -c fish-commands-scrapper \\ -n 'not string match -q -- "*--write*" (commandline -poc); and not string match -q -- "*--diff*" (commandline -poc)' \\ -l write -f \\ -d 'Write JSON to snippets/.json' # --diff flag (only if not already present and not --write) complete -c fish-commands-scrapper \\ -n 'not string match -q -- "*--diff*" (commandline -poc); and not string match -q -- "*--write*" (commandline -poc)' \\ -s d -l diff -f \\ -d 'Show diff vs existing files' # --help flag complete -c fish-commands-scrapper \\ -s h -l help -f \\ -d 'Show help message' # --completions flag complete -c fish-commands-scrapper \\ -s c -l completions -f \\ -d 'Output Fish completions' # Disable file completions when --show is set and not typing a flag complete -c fish-commands-scrapper \\ -n 'string match -q -- "*--show=*" (commandline -poc); and not string match -q -- "--*" (commandline -ct)' \\ -f # yarn generate:snippets - Register the subcommand complete -c yarn -f -n '__fish_use_subcommand' -a 'generate:snippets' -d 'Generate Fish snippets' # Helper to complete --show values (inline completions after comma) complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets; and string match -q -- "--show=*" (commandline -ct)' \\ -f \\ -a '(__fish_fcs_show_candidates)' \\ -d 'Dataset' # Helper to provide --show flag completion (only if not already present) complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets; and not string match -q -- "*--show=*" (commandline -poc)' \\ -l show -x \\ -a '(__fish_fcs_show_candidates)' \\ -d 'Dataset' # --write flag (only if not already present and not --diff) complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets; and not string match -q -- "*--write*" (commandline -poc); and not string match -q -- "*--diff*" (commandline -poc)' \\ -l write -f \\ -d 'Write JSON to snippets/.json' # --diff flag (only if not already present and not --write) complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets; and not string match -q -- "*--diff*" (commandline -poc); and not string match -q -- "*--write*" (commandline -poc)' \\ -s d -l diff -f \\ -d 'Show diff vs existing files' # --help flag complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets' \\ -s h -l help -f \\ -d 'Show help message' # --completions flag complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets' \\ -s c -l completions -f \\ -d 'Output Fish completions' # Disable file completions for generate:snippets when --show is set and we're not typing a flag complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets; and string match -q -- "*--show=*" (commandline -poc); and not string match -q -- "--*" (commandline -ct)' \\ -f # Provide long-form flags as completions when no prefix is typed complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets; and not string match -q -- "-*" (commandline -ct)' \\ -f -k -a " --show=\\t'dataset to show (commands, special-variables, env-variables, functions)' --write\\t'write JSON to snippets/.json' -d\\t'show diff vs existing files' --diff\\t'show diff vs existing files' -c\\t'output Fish completions' --completions\\t'output Fish completions' -h\\t'show help message' --help\\t'show help message'" # Disable file completions for generate:snippets completely complete -c yarn \\ -n '__fish_seen_subcommand_from generate:snippets' \\ -f `; console.log(completionScript); } async function fetchFishCommands(): Promise { try { // Fetch the HTML content from the Fish shell documentation const response = await fetch('https://fishshell.com/docs/current/commands.html'); const html = await response.text(); // Parse the HTML using JSDOM const dom = new JSDOM(html); const document = dom.window.document; // Find all list items that contain command references const commandItems = document.querySelectorAll('li.toctree-l1 a.reference.internal'); const commands: FishCommand[] = []; // Process each command item commandItems.forEach((item) => { const linkText = item.textContent?.trim() || ''; // Check if this is a command reference // Command references typically follow the pattern: "command - description" if (linkText.includes(' - ')) { const [name, description] = linkText.split(' - ', 2); commands.push({ name: name.trim(), description: description.trim(), }); } }); return commands; } catch (error) { console.error('Error fetching Fish commands:', error); return []; } } async function fetchSpecialVariables(...keys: ('special-variables' | 'env-variables')[]): Promise { try { // Fetch the HTML content for language documentation const url = 'https://fishshell.com/docs/current/language.html'; const response = await fetch(url); const html = await response.text(); // Parse the HTML using JSDOM const dom = new JSDOM(html); const document = dom.window.document; const specialVariables: FishCommand[] = []; // Find the element with the 'special-variables' ID (usually an anchor or heading) const headingWithId = document.querySelector('#special-variables'); // Find the section container that holds the variable list const specialVariablesSection = headingWithId?.closest('section'); if (!specialVariablesSection) { console.error(`Could not find the section for special variables on ${url}`); return []; } // Special variables are typically documented as a Definition List (
) // with
for the variable name and
for the description. const definitionTerms = specialVariablesSection.querySelectorAll('section#special-variables>dl'); definitionTerms.forEach((dt) => { // `section#special-variables>dl dt > span` is the name key // dl.std:nth-child(12) > dd:nth-child(2) > p:nth-child(1) // console.log(dt.querySelector('dt>span')?.textContent); // console.log(dt.querySelector('dd>p')?.textContent.toString()); const label = dt.querySelector('dt>span')?.textContent?.trim() || ''; const desc = dt.querySelector('dd>p')?.textContent?.trim() || ''; if (label.includes(' and ')) { label.split(' and ').forEach((part) => { specialVariables.push({ name: part.trim(), description: desc, }); }) return; } specialVariables.push({ name: label, description: desc, }); }) // The variable name is usually in a 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 { 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 [ '```man', splitDocs.slice(startIndex).join('\n'), '```', ].join('\n'); } function ensureMinLength(arr: T[], minLength: number, fillValue?: T): T[] { while (arr.length < minLength) { arr.push(fillValue as T); } return arr; } ================================================ FILE: src/utils/completion/inline-parser.ts ================================================ import Parser, { SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from '../../parser'; import { getChildNodes, getLeafNodes, getLastLeafNode, firstAncestorMatch } from '../tree-sitter'; import { isUnmatchedStringCharacter, isPartialForLoop } from '../node-types'; import { FishCompletionItem } from './types'; export class InlineParser { private readonly COMMAND_TYPES = ['command', 'for_statement', 'case', 'function']; static async create() { const parser = await initializeParser(); return new InlineParser(parser); } constructor(private parser: Parser) { this.parser = parser; } /** * returns a context aware node, which represents the current word * where the completion list is being is requested. * ________________________________________ * | line | word | * |----------------|----------------------| * | `ls -` | `-` | * |----------------|----------------------| * | `ls ` | `null` | * ----------------------------------------- */ parseWord(line: string): { wordNode: SyntaxNode | null; word: string | null; } { if (line.endsWith(' ') || line.endsWith('(')) { return { word: null, wordNode: null }; } const { rootNode } = this.parser.parse(line); //let node = rootNode.descendantForPosition({row: 0, column: line.length-1}); //const node = getLastLeaf(rootNode); const node = getLastLeafNode(rootNode); if (!node || node.text.trim() === '') { return { word: null, wordNode: null }; } return { word: node.text.trim() + line.slice(node.endIndex), wordNode: node, }; } /** * Returns a command SyntaxNode if one is seen on the current line. * Will return null if a command is needed at the current cursor. * Later will be useful to narrow down, which possible types of FishCompletionItems * should be sent to the client, based on the command. * ─────────────────────────────────────────────────────────────────────────────── * • Some examples of the expected behavior can be seen below: * ─────────────────────────────────────────────────────────────────────────────── * '', 'switch', 'if', 'while', ';', 'and', 'or', ⟶ returns 'null' * ─────────────────────────────────────────────────────────────────────────────── * 'for ...', 'case ...', 'function ...', 'end ', ⟶ returns 'command' node shown * ─────────────────────────────────────────────────────────────────────────────── */ parseCommand(line: string) : { command: string | null; commandNode: SyntaxNode | null; } { const { word, wordNode } = this.parseWord(line.trimEnd()); if (wordPrecedesCommand(word)) { return { command: null, commandNode: null }; } const { virtualLine, maxLength } = Line.appendEndSequence(line, wordNode); const { rootNode } = this.parser.parse(virtualLine); const node = getLastLeafNode(rootNode, maxLength); if (!node) { return { command: null, commandNode: null }; } let commandNode = firstAncestorMatch(node, (n) => this.COMMAND_TYPES.includes(n.type)); commandNode = commandNode?.firstChild || commandNode; return { command: commandNode?.text || null, commandNode: commandNode || null, }; } parse(line: string): SyntaxNode { this.parser.reset(); return this.parser.parse(line).rootNode; } getNodeContext(line: string) { const { word, wordNode } = this.parseWord(line); const { command, commandNode } = this.parseCommand(line); const index = this.getIndex(line); if (word === command) { return { word, wordNode, command: null, commandNode: null, index: 0 }; } return { word, wordNode, command, commandNode, //last, //lastNode, index: index, }; } lastItemIsOption(line: string): boolean { const { command } = this.parseCommand(line); if (!command) { return false; } const afterCommand = line.lastIndexOf(command) + 1; const lastItem = line.slice(afterCommand).trim().split(' ').at(-1); if (lastItem) { return lastItem.startsWith('-'); } return false; } getLastNode(line: string): SyntaxNode | null { const { wordNode } = this.parseWord(line.trimEnd()); //if (wordPrecedesCommand(word)) return {command: null, commandNode: null}; const { virtualLine, maxLength: _maxLength } = Line.appendEndSequence(line, wordNode); const rootNode = this.parse(virtualLine); const node = getLastLeafNode(rootNode); return node; } hasOption(command: SyntaxNode, options: string[]) { return getChildNodes(command).some(n => options.includes(n.text)); } getIndex(line: string): number { const { commandNode } = this.parseCommand(line); if (!commandNode) { return 0; } if (commandNode) { const node = firstAncestorMatch(commandNode, (n) => this.COMMAND_TYPES.includes(n.type))!; const allLeafNodes = getLeafNodes(node).filter(leaf => leaf.startPosition.column < line.length); return Math.max(allLeafNodes.length - 1, 1); } return 0; } async createCompletionList(line: string): Promise { const result: FishCompletionItem[] = []; const { word: _word, wordNode: _wordNode, commandNode: _commandNode } = this.getNodeContext(line); return result; } } /** * Checks input 'word' against lists of strings that represent fish shell tokens that * denote the next item could be a command. The tokens seen below, are mostly commands * that should be treated specially (to help determine the current completion context) * * @param {string | null} word - the current word which might not exists * @returns {boolean} - True if the word is a token that precedes a command. * False if the word is not something that precedes a command, (i.e. a flag) */ export function wordPrecedesCommand(word: string | null) { if (!word) { return false; } const chars = ['(', ';']; const combiners = ['and', 'or', 'not', '!', '&&', '||']; const conditional = ['if', 'while', 'else if', 'switch']; const pipes = ['|', '&', '1>|', '2>|', '&|']; return ( chars.includes(word) || combiners.includes(word) || conditional.includes(word) || pipes.includes(word) ); } /** * Helper functions to edit lines in the CompletionList methods. */ export namespace Line { export function isEmpty(line: string): boolean { return line.trim().length === 0; } export function isComment(line: string): boolean { return line.trim().startsWith('#'); } export function hasMultipleLastSpaces(line: string): boolean { return line.trim().endsWith(' '); } export function removeAllButLastSpace(line: string): string { if (line.endsWith(' ')) { return line; } return line.split(' ')[-1] || line; } export function appendEndSequence( oldLine: string, wordNode: SyntaxNode | null, endSequence: string = ';end;', ) { let virtualEOLChars = endSequence; let maxLength = oldLine.length; if (wordNode && isUnmatchedStringCharacter(wordNode)) { virtualEOLChars = wordNode.text + endSequence; maxLength -= 1; } if (wordNode && isPartialForLoop(wordNode)) { const completeForLoop = ['for', 'i', 'in', '_']; const errorNode = firstAncestorMatch(wordNode, (n) => n.hasError, )!; const leafNodes = getLeafNodes(errorNode); virtualEOLChars = ' ' + completeForLoop.slice(leafNodes.length).join(' ') + endSequence; } return { virtualLine: [oldLine, virtualEOLChars].join(''), virtualEOLChars: virtualEOLChars, maxLength: maxLength, }; } } ================================================ FILE: src/utils/completion/list.ts ================================================ import { FishCompletionData, FishCompletionItem, toCompletionItemKind } from './types'; import { FishSymbol } from '../../parsing/symbol'; import { Logger } from '../../logger'; import { CompletionItemKind, CompletionList, SymbolKind } from 'vscode-languageserver'; export class FishCompletionListBuilder { private items: FishCompletionItem[]; private data: FishCompletionData = {} as FishCompletionData; constructor( private logger: Logger, ) { this.items = []; } addItem(item: FishCompletionItem) { this.items.push(item); } addItems(items: FishCompletionItem[], priority?: number) { if (priority) { items = items.map((item) => item.setPriority(priority)); } this.items.push(...items); } addSymbols(symbols: FishSymbol[], insertDollarSign: boolean = false) { const symbolItems = symbols.map((symbol) => { if (insertDollarSign && symbol.kind === SymbolKind.Variable) { return { ...FishCompletionItem.fromSymbol(symbol), label: '$' + symbol.name, } as FishCompletionItem; } return FishCompletionItem.fromSymbol(symbol); }); this.items.push(...symbolItems); } addData(data: FishCompletionData) { this.items = this.items.map((item: FishCompletionItem) => { if (!data.line.endsWith(' ')) { const newData = { ...data, line: data.line.slice(0, data.line.length - data.word.length) + item.label, } as FishCompletionData; return item.setData(newData); } return item; }); return this; } reset() { this.items = []; } sortByPriority(items: FishCompletionItem[]): FishCompletionItem[] { // Default priority is higher than any explicitly set priority // (higher number = lower display priority) const DEFAULT_PRIORITY = 1000; const getFallbackPrioriy = (item: FishCompletionItem) => { if (item.kind === CompletionItemKind.Property) { return 1005; } if (item.kind === CompletionItemKind.Class) { return 10; } if (item.kind === CompletionItemKind.Function) { return 50; } if (item.kind === CompletionItemKind.Variable) { return 100; } return DEFAULT_PRIORITY; }; return items.sort((a, b) => { // Get priorities with fallback to default const priorityA = a.priority !== undefined ? a.priority : getFallbackPrioriy(a); const priorityB = b.priority !== undefined ? b.priority : getFallbackPrioriy(b); // Compare priorities (lower number = higher display priority) if (priorityA !== priorityB) { return priorityA - priorityB; } // If priorities are the same or both undefined, fall back to alphabetical sorting return a.label.localeCompare(b.label); }); } build(isIncomplete: boolean = false): FishCompletionList { const uniqueItems = this.items.filter((item, index, self) => index === self.findIndex((t) => t.label === item.label), ); const sortedItems = this.sortByPriority(uniqueItems); return FishCompletionList.create(isIncomplete, this.data, sortedItems); } log() { const result = this.items.map((item, index) => itemLoggingInfo(item, index)); this.logger.log('CompletionList', result); } get _logger() { return this.logger; } } function itemLoggingInfo(item: FishCompletionItem, index: number) { return { index, label: item.label, detail: item.detail, kind: toCompletionItemKind[item.fishKind], fishKind: item.fishKind, documentation: item.documentation, data: item.data, }; } export type FishCompletionList = CompletionList; export namespace FishCompletionList { export function empty() { return { isIncomplete: false, items: [] as FishCompletionItem[], } as FishCompletionList; } export function create( isIncomplete: boolean, data: FishCompletionData, items: FishCompletionItem[] = [] as FishCompletionItem[], ) { return { isIncomplete, items, itemDefaults: { data, }, } as FishCompletionList; } } ================================================ FILE: src/utils/completion/pager.ts ================================================ import { FishSymbol } from '../../parsing/symbol'; import { FishCompletionItem } from './types'; import { execCompleteLine } from '../exec'; import { logger, Logger } from '../../logger'; import { InlineParser } from './inline-parser'; import { CompletionItemMap } from './startup-cache'; import { CompletionContext, CompletionList, Position, SymbolKind } from 'vscode-languageserver'; import { FishCompletionList, FishCompletionListBuilder } from './list'; import { shellComplete } from './shell'; import { isVariableDefinitionName } from '../../parsing/barrel'; import { isOption, isCommandWithName, isVariableExpansion } from '../../utils/node-types'; import * as SetParser from '../../parsing/set'; import * as ReadParser from '../../parsing/read'; import * as ArgparseParser from '../../parsing/argparse'; import * as ForParser from '../../parsing/for'; import * as FunctionParser from '../../parsing/function'; import { LspDocument } from '../../document'; import { SyntaxNode } from 'web-tree-sitter'; export type SetupData = { uri: string; position: Position; context: CompletionContext; }; export class CompletionPager { private _items: FishCompletionListBuilder; constructor( private inlineParser: InlineParser, private itemsMap: CompletionItemMap, private logger: Logger, ) { this._items = new FishCompletionListBuilder(this.logger); } empty(): CompletionList { return { items: [] as FishCompletionItem[], isIncomplete: false, }; } create( isIncomplete: boolean, items: FishCompletionItem[] = [] as FishCompletionItem[], ) { return { isIncomplete, items, } as CompletionList; } async completeEmpty( symbols: FishSymbol[], ): Promise { this._items.reset(); this._items.addSymbols(symbols, true); this._items.addItems(this.itemsMap.allOfKinds('builtin').map(item => item.setPriority(10))); try { const stdout: [string, string][] = []; const toAdd = await this.getSubshellStdoutCompletions(' '); stdout.push(...toAdd); for (const [name, description] of stdout) { this._items.addItem(FishCompletionItem.create(name, 'command', description, name).setPriority(1)); } } catch (e) { logger.info('Error getting subshell stdout completions', e); } this._items.addItems(this.itemsMap.allOfKinds('comment').map(item => item.setPriority(95))); this._items.addItems(this.itemsMap.allOfKinds('function').map(item => item.setPriority(30))); return this._items.build(false); } async completeVariables( line: string, word: string, setupData: SetupData, symbols: FishSymbol[], ): Promise { this._items.reset(); const data = FishCompletionItem.createData( setupData.uri, line, word || '', setupData.position, ); // Analyze the context to determine how to format the insertText const lineBeforeCursor = line; const cursorPos = setupData.position.character; // Find how many $ characters precede the current word let wordStartPos = cursorPos; while (wordStartPos > 0) { const char = lineBeforeCursor[wordStartPos - 1]; // Stop at whitespace or when we find a $ ($ is prefix, not part of word) if (char === ' ' || char === '\t' || char === '\n' || char === '$') { break; } wordStartPos--; } // Count $ characters before the word let dollarsBeforeWord = 0; for (let i = wordStartPos - 1; i >= 0 && lineBeforeCursor[i] === '$'; i--) { dollarsBeforeWord++; } // Check if we're in a variable definition context (commands like 'set', 'read', etc.) const isVariableDefinitionContext = this.isInVariableDefinitionContext(lineBeforeCursor, setupData.position); // Count $ characters in the word itself (e.g., word="$" has 1, word="PA" has 0) const dollarsInWord = (word.match(/\$/g) || []).length; // Determine the correct insertText format // We need $ prefix if: // 1. No dollars before word AND no dollars in word AND not in variable definition context // 2. OR if the word itself contains $ characters (to replace them) const shouldAddDollarPrefix = dollarsBeforeWord === 0 && dollarsInWord === 0 && !isVariableDefinitionContext || dollarsInWord > 0; // For words containing $ characters, we need to include the right number of $ const dollarPrefix = dollarsInWord > 0 ? '$'.repeat(dollarsInWord) : shouldAddDollarPrefix ? '$' : ''; const { variables } = sortSymbols(symbols); for (const variable of variables) { const variableItem = FishCompletionItem.fromSymbol(variable); variableItem.insertText = dollarPrefix + variable.name; this._items.addItem(variableItem); } const mapVariables = this.itemsMap.allOfKinds('variable'); for (const item of mapVariables) { if (!item.label) { continue; } // Create a new completion item based on the original const newItem = FishCompletionItem.create( item.label, item.fishKind, item.detail, typeof item.documentation === 'string' ? item.documentation : item.documentation?.toString && item.documentation.toString() || '', item.examples, ); newItem.insertText = dollarPrefix + item.label; this._items.addItem(newItem); } const result = this._items.addData(data).build(); result.isIncomplete = false; return result; } /** * Determines if the current line context is for variable definition using proper syntax tree analysis * (e.g., set, read commands where variables don't need $ prefix) */ private isInVariableDefinitionContext(lineBeforeCursor: string, position: Position): boolean { try { // Parse the line to get the syntax tree const rootNode = this.inlineParser.parse(lineBeforeCursor); if (!rootNode) { return false; } // Find the node at the current position const currentNode = rootNode.descendantForPosition({ row: 0, column: Math.max(0, position.character - 1), }); if (!currentNode) { return false; } // Check if we're in a context where we'd be defining a variable name // This includes set, read, argparse, for, function parameter, and export contexts // First check if the current node itself is a variable definition if (isVariableDefinitionName(currentNode)) { return true; } // Check if the parent might be a variable definition context // This handles cases where we're about to complete a variable name if (currentNode.parent) { const grandParent = currentNode.parent.parent; // For set commands: check if we're in position to define a variable if (grandParent && isCommandWithName(grandParent, 'set')) { // Skip if it's a query operation (set -q) if (SetParser.isSetQueryDefinition(grandParent)) { return false; // set -q should use $ prefixes for variable references } // Check if we're in the variable name position for set const setChildren = SetParser.findSetChildren(grandParent); const firstNonOption = setChildren.find(child => !isOption(child)); if (firstNonOption && (firstNonOption.equals(currentNode) || firstNonOption.equals(currentNode.parent))) { return true; } } // For read commands: check if we're in position to define a variable if (grandParent && isCommandWithName(grandParent, 'read')) { const { definitionNodes } = ReadParser.findReadChildren(grandParent); if (definitionNodes.some(node => node.equals(currentNode) || currentNode.parent && node.equals(currentNode.parent))) { return true; } } // For argparse commands: check if we're defining a variable name if (grandParent && isCommandWithName(grandParent, 'argparse')) { const nodes = ArgparseParser.findArgparseDefinitionNames(grandParent); if (nodes.some(node => node.equals(currentNode) || currentNode.parent && node.equals(currentNode.parent))) { return true; } } // For for loops: check if we're defining the loop variable if (grandParent && isCommandWithName(grandParent, 'for')) { if (grandParent.firstNamedChild && ForParser.isForVariableDefinitionName(grandParent.firstNamedChild)) { return true; } } // For function definitions: check if we're defining function parameters/arguments if (grandParent && isCommandWithName(grandParent, 'function')) { const { variableNodes } = FunctionParser.findFunctionOptionNamedArguments(grandParent); if (variableNodes.some(node => node.equals(currentNode) || currentNode.parent && node.equals(currentNode.parent))) { return true; } } } return false; } catch (error) { // Fallback to false if parsing fails return false; } } async complete( line: string, setupData: SetupData, symbols: FishSymbol[], ): Promise { const { word, command, commandNode: _commandNode, index } = this.inlineParser.getNodeContext(line || ''); logger.log({ line, word: word, command: command, index: index, }); this._items.reset(); const data = FishCompletionItem.createData( setupData.uri, line || '', word || '', setupData.position, command || '', setupData.context, ); const { variables, functions } = sortSymbols(symbols); if (!word && !command) { return this.completeEmpty(symbols); } const stdout: [string, string][] = []; if (command && this.itemsMap.blockedCommands.includes(command)) { this._items.addItems(this.itemsMap.allOfKinds('pipe'), 85); return this._items.build(false); } const toAdd = await shellComplete(line); stdout.push(...toAdd); logger.log('toAdd =', toAdd.slice(0, 5)); if (word && word.includes('/')) { this.logger.log('word includes /', word); const toAdd = await this.getSubshellStdoutCompletions(`__fish_complete_path ${word}`); this._items.addItems(toAdd.map((item) => FishCompletionItem.create(item[0], 'path', item[1], item.join(' '))), 1); } const isOption = this.inlineParser.lastItemIsOption(line); for (const [name, description] of stdout) { if (isOption || name.startsWith('-') || command) { this._items.addItem(FishCompletionItem.create(name, 'argument', description, [ line.slice(0, line.lastIndexOf(' ')), name, ].join(' ').trim()).setPriority(1)); continue; } const item = this.itemsMap.findLabel(name); if (!item) { continue; } this._items.addItem(item.setPriority(1)); } if (command && line.includes(' ')) { this._items.addSymbols(variables); if (index === 1) { this._items.addItems(addFirstIndexedItems(command, this.itemsMap), 25); } else { this._items.addItems(addSpecialItems(command, line, this.itemsMap), 24); } } else if (word && !command) { this._items.addSymbols(functions); } switch (wordsFirstChar(word)) { case '$': this._items.addItems(this.itemsMap.allOfKinds('variable'), 55); // For $ prefixed words, add symbols without duplicate $ handling via completeVariables this._items.addSymbols(variables); break; case '/': this._items.addItems(this.itemsMap.allOfKinds('wildcard')); //let addedStdout = await this.getSubshellStdoutCompletions(word!) //stdout = stdout.concat(addedStdout) break; default: break; } const result = this._items.addData(data).build(); // this._items.log(); return result; } getData(uri: string, position: Position, line: string, word: string) { return { uri, position, line, word, }; } private async getSubshellStdoutCompletions( line: string, ): Promise<[string, string][]> { const resultItem = (splitLine: string[]) => { const name = splitLine[0] || ''; const description = splitLine.length > 1 ? splitLine.slice(1).join(' ') : ''; return [name, description] as [string, string]; }; const outputLines = await execCompleteLine(line); return outputLines .filter((line) => line.trim().length !== 0) .map((line) => line.split('\t')) .map((splitLine) => resultItem(splitLine)); } } export async function initializeCompletionPager(logger: Logger, items: CompletionItemMap) { const inline = await InlineParser.create(); return new CompletionPager(inline, items, logger); } function addFirstIndexedItems(command: string, items: CompletionItemMap) { switch (command) { case 'functions': case 'function': return items.allOfKinds('event', 'variable'); case 'end': return items.allOfKinds('pipe'); case 'printf': return items.allOfKinds('format_str', 'esc_chars'); case 'set': return items.allOfKinds('variable'); case 'return': return items.allOfKinds('status', 'variable'); default: return []; } } function addSpecialItems( command: string, line: string, items: CompletionItemMap, ) { const lastIndex = line.lastIndexOf(command) + 1; const afterItems = line.slice(lastIndex).trim().split(' '); const lastItem = afterItems.at(-1); switch (command) { //case "end": // return items.allOfKinds("pipe"); case 'return': return items.allOfKinds('status', 'variable'); case 'printf': case 'set': return items.allOfKinds('variable'); case 'function': switch (lastItem) { case '-e': case '--on-event': return items.allOfKinds('event'); case '-v': case '--on-variable': case '-V': case '--inherit-variable': return items.allOfKinds('variable'); default: return []; } case 'string': if (includesFlag('-r', '--regex', ...afterItems)) { return items.allOfKinds('regex', 'esc_chars'); } else { return items.allOfKinds('esc_chars'); } default: return items.allOfKinds('combiner', 'pipe'); } } function wordsFirstChar(word: string | null) { return word?.charAt(0) || ' '; } function includesFlag( shortFlag: string, longFlag: string, ...toSearch: string[] ) { const short = shortFlag.startsWith('-') ? shortFlag.slice(1) : shortFlag; const long = longFlag.startsWith('--') ? longFlag.slice(2) : longFlag; for (const item of toSearch) { if (item.startsWith('-') && !item.startsWith('--')) { const opts = item.slice(1).split(''); if (opts.some((opt) => opt === short)) { return true; } } if (item.startsWith('--')) { const opts = item.slice(2).split(''); if (opts.some((opt) => opt === long)) { return true; } } } return false; } function sortSymbols(symbols: FishSymbol[]) { const variables: FishSymbol[] = []; const functions: FishSymbol[] = []; symbols.forEach((symbol) => { if (symbol.kind === SymbolKind.Variable) { variables.push(symbol); } if (symbol.kind === SymbolKind.Function) { functions.push(symbol); } }); return { variables, functions }; } /** * Determines if the current position is within a variable expansion context. * This handles cases like: * - echo $P (cursor after P) * - echo $$P (cursor after P) * - echo $$$PA (cursor after PA) * - echo (cursor after space - could start variable expansion) * - set -q (cursor after space - variable definition context) */ export function isInVariableExpansionContext(doc: LspDocument, position: Position, line: string, word: string, current: SyntaxNode | null): boolean { // Original logic for simple cases if (word.trim().endsWith('$') || line.trim().endsWith('$') || word.trim() === '$' && !word.startsWith('$$')) { return true; } // Check if we're directly in a variable expansion node if (current && isVariableExpansion(current)) { return true; } // Check if the parent is a variable expansion if (current?.parent && isVariableExpansion(current.parent)) { return true; } // Look at the text preceding the current position to detect $ prefixes const lineBeforeCursor = doc.getLineBeforeCursor(position); const charIndex = position.character; // Find the position where the current word starts (excluding $ prefixes) let wordStartPos = charIndex; while (wordStartPos > 0) { const char = lineBeforeCursor[wordStartPos - 1]; // Stop if we hit whitespace or if we hit a $ character ($ is prefix, not part of word) if (char === ' ' || char === '\t' || char === '\n' || char === '$') { break; } wordStartPos--; } // Now look backwards from wordStartPos to count $ characters let dollarsBeforeWord = 0; for (let i = wordStartPos - 1; i >= 0 && lineBeforeCursor[i] === '$'; i--) { dollarsBeforeWord++; } // If there are $ characters before the current word, we're in variable expansion context if (dollarsBeforeWord > 0) { return true; } // Check for contexts where variables are commonly used (check original line, not trimmed) if (line === 'echo ' || line === 'set -q ' || line.startsWith('set ') && line.endsWith(' ')) { return true; } return false; } ================================================ FILE: src/utils/completion/shell.ts ================================================ import { execAsync } from '../exec'; export function escapeCmd(cmd: string): string { return cmd .replace(/\\/g, '\\\\') // Escape backslashes first! .replace(/\$/g, '\\$') // Then escape $ .replace(/'/g, "\\'") // Then escape quotes .replace(/`/g, '\\`') .replace(/"/g, '\\"'); } export async function shellComplete(cmd: string): Promise<[string, string][]> { // escape the `"`, and `'` characters. // const escapedCmd = cmd.replace(/(["'`\\])/g, '\\$1'); // const escapedCmd = cmd.replace(/(["'])/g, '\\$1'); const escapedCmd = escapeCmd(cmd).toString(); // const completeString = `fish -c "complete --do-complete='${escapedCmd}'"`; const completeString = `fish -c "complete --do-complete='${escapedCmd}'"`; // Using the `--escape` flag will include extra backslashes in the output // for example, 'echo "$' -> ['\"$PATH', '\"$PWD', ...] // const completeString = `fish -c "complete --escape --do-complete='${escapedCmd}'"`; const child = await execAsync(completeString); if (child.stderr) { return []; } return child.stdout.toString().trim() .split('\n') .filter((line) => line.trim() !== '') .map(line => { const [first, ...rest] = line.split('\t'); // Remove surrounding quotes from the first item // const unquotedFirst = first.replace(/^(['"])(.*)\1$/, '$2'); return [first, rest.join('\t') || ''] as [string, string]; }); } ================================================ FILE: src/utils/completion/startup-cache.ts ================================================ import { FishCompletionItem, FishCompletionItemKind } from './types'; import { StaticItems } from './static-items'; import { runSetupItems, SetupItemsFromCommandConfig } from './startup-config'; import { md } from '../markdown-builder'; export type ItemMapRecord = Record; export class CompletionItemMap { constructor(private _items: ItemMapRecord = {} as ItemMapRecord) { } static async initialize(): Promise { const result: ItemMapRecord = {} as ItemMapRecord; const cmdOutputs: Map = new Map(); const topLevelLabels: Set = new Set(); const setupResults = await runSetupItems(); for (const item of setupResults) { cmdOutputs.set(item.fishKind, item.results); } SetupItemsFromCommandConfig.forEach((item) => { const items: FishCompletionItem[] = []; const stdout = cmdOutputs.get(item.fishKind)!; stdout.forEach((line) => { if (line.trim().length === 0) { return; } const { label, value } = splitLine(line); if (item.topLevel) { if (topLevelLabels.has(label)) { return; } topLevelLabels.add(label); } const detail = getCommandsDetail(value || item.detail); items.push(FishCompletionItem.create(label, item.fishKind, detail, line)); }); result[item.fishKind] = items; }); Object.entries(StaticItems).forEach(([key, value]) => { const kind = key as FishCompletionItemKind; if (!result[kind]) { result[kind] = value.map((item) => FishCompletionItem.create( item.label, kind, item.detail, item.documentation.toString(), item.examples, )); } if (kind === FishCompletionItemKind.FUNCTION || kind === FishCompletionItemKind.VARIABLE) { const toAdd = value .filter((item) => !result[kind].find((i) => i.label === item.label)) .map((item) => FishCompletionItem.create( item.label, kind, item.detail, [ `(${md.italic(kind)}) ${md.bold(item.label)}`, md.separator(), item.documentation.toString(), ].join('\n'), item.examples, ).setUseDocAsDetail()); result[kind].push(...toAdd); } }); return new CompletionItemMap(result); } get(kind: FishCompletionItemKind): FishCompletionItem[] { return this._items[kind] || []; } get allKinds(): FishCompletionItemKind[] { return Object.keys(this._items) as FishCompletionItemKind[]; } allOfKinds(...kinds: FishCompletionItemKind[]): FishCompletionItem[] { return kinds.reduce((acc, kind) => acc.concat(this.get(kind)), [] as FishCompletionItem[]); } entries(): [FishCompletionItemKind, FishCompletionItem[]][] { return Object.entries(this._items) as [FishCompletionItemKind, FishCompletionItem[]][]; } forEach(callbackfn: (key: FishCompletionItemKind, value: FishCompletionItem[]) => void) { this.entries().forEach(([key, value]) => callbackfn(key, value)); } allCompletionsWithoutCommand() { return this.allOfKinds( FishCompletionItemKind.ABBR, FishCompletionItemKind.ALIAS, FishCompletionItemKind.BUILTIN, FishCompletionItemKind.FUNCTION, FishCompletionItemKind.COMMAND, // FishCompletionItemKind.VARIABLE, ); } findLabel(label: string, ...searchKinds: FishCompletionItemKind[]): FishCompletionItem | undefined { const kinds: FishCompletionItemKind[] = searchKinds?.length > 0 ? searchKinds : this.allKinds; for (const kind of kinds) { const item = this.get(kind).find((item) => item.label === label); if (item) { return item; } } return undefined; } get blockedCommands() { return [ 'end', 'else', 'continue', 'break', ]; } } export function splitLine(line: string): { label: string; value?: string; } { const index = line.search(/\s/); // This looks for the first whitespace character if (index === -1) { return { label: line }; } const label = line.slice(0, index); const value = line.slice(index).trimStart(); // No need to add 1 since you want to retain the whitespace in value. return { label, value }; } function getCommandsDetail(value: string) { if (value.trim().length === 0) { return 'command'; } if (value.startsWith('alias')) { return 'alias'; } if (value === 'command link') { return 'command'; } if (value === 'command') { return 'command'; } return value; } ================================================ FILE: src/utils/completion/startup-config.ts ================================================ import { config } from '../../config'; import { FishCompletionItemKind } from './types'; export type SetupItem = { command: string; detail: string; fishKind: FishCompletionItemKind; topLevel: boolean; }; export const SetupItemsFromCommandConfig: SetupItem[] = [ // { // command: `[ (abbr --show | count) -eq 0 ] || abbr --show | string split ' -- ' -m1 -f2 | string unescape`, // detail: 'Abbreviation', // fishKind: FishCompletionItemKind.ABBR, // topLevel: true, // }, { command: 'builtin --names', detail: 'Builtin', fishKind: FishCompletionItemKind.BUILTIN, topLevel: true, }, { command: '[ (alias | count) -eq 0 ] || alias | string collect | string unescape | string split \' \' -m1 -f2', detail: 'Alias', fishKind: FishCompletionItemKind.ALIAS, topLevel: true, }, { command: 'functions --all --names | string collect', detail: 'Function', fishKind: FishCompletionItemKind.FUNCTION, topLevel: true, }, { // TODO: Confirm if `mkdir` is included in the output of this command (issue #154) // @see https://github.com/ndonfris/fish-lsp/issues/154 for more details command: 'complete --do-complete \'\' | string match --regex --entire -- \'^\\S+\\s+command(?: link)?\$\'', // NOTE: keeping the argument ( ^^ ) above seems to prevent fish from needing to be // started with `--interactive` switch, saving ~100ms of time during execution // of all commands defined here. detail: 'Command', fishKind: FishCompletionItemKind.COMMAND, topLevel: true, }, { command: 'set --names', detail: 'Variable', fishKind: FishCompletionItemKind.VARIABLE, topLevel: false, }, { command: '[ (functions --handlers | count) -eq 0 ] || functions --handlers | string match -vr \'^Event \\w+\'', detail: 'Event Handler', fishKind: FishCompletionItemKind.EVENT, topLevel: false, }, ]; import { spawn } from 'child_process'; export type SetupResult = SetupItem & { results: string[]; }; export async function runSetupItems( items: SetupItem[] = SetupItemsFromCommandConfig, ): Promise { const DELIMITER = `### __FISH_LSP_SEP__:${Math.random().toString(36)}:__FISH_LSP_SEP__ ###`; // build a single script that runs all commands in sequence, separating outputs with a unique delimiter const script = items .map((item) => `printf '${DELIMITER}'; begin; ${item.command}; end 2>/dev/null`) .join('\n'); const shellCommand = config.fish_lsp_fish_path || 'fish'; const output = await new Promise((resolve, reject) => { const proc = spawn(shellCommand, ['-Pc', script]); let stdout = ''; proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); proc.on('close', () => resolve(stdout)); proc.on('error', reject); }); // First segment is empty (delimiter is printed before each command) const segments = output.split(DELIMITER).slice(1); // results are split by delimiter, and then we map them back to items return items.map((item, i) => ({ ...item, results: (segments[i] ?? '').split('\n').filter(Boolean), })); } ================================================ FILE: src/utils/completion/static-items.ts ================================================ import { CompletionItemKind } from 'vscode-languageserver'; import { ErrorCodes } from '../../diagnostics/error-codes'; import { md } from '../markdown-builder'; import { FishCompletionItem, FishCompletionItemKind, CompletionExample } from './types'; import { PrebuiltDocumentationMap } from '../snippets'; const EscapedChars: FishCompletionItem[] = [ { label: '\\a', detail: 'alert character', documentation: 'escapes the alert character', }, { label: '\\b', detail: 'backspace character', documentation: 'escapes the backspace character', }, { label: '\\e', detail: 'escape character', documentation: 'escapes the escape character', }, { label: '\\f', detail: 'form feed character', documentation: 'escapes the form feed character', }, { label: '\\n', detail: 'newline character', documentation: 'escapes a newline character', }, { label: '\\r', detail: 'carriage return character', documentation: 'escapes the carriage return character', }, { label: '\\t', detail: 'tab character', documentation: 'escapes the tab character', }, { label: '\\v', detail: 'vertical tab character', documentation: 'escapes the vertical tab character', }, { label: '\\ ', detail: 'space character', documentation: 'escapes the space character', }, { label: '\\$', detail: 'dollar character', documentation: 'escapes the dollar character', }, { label: '\\\\', detail: 'backslash character', documentation: 'escapes the backslash character', }, { label: '\\*', detail: 'star character', documentation: 'escapes the star character', }, { label: '\\?', detail: 'question mark character', documentation: 'escapes the question mark character', }, { label: '\\~', detail: 'tilde character', documentation: 'escapes the tilde character', }, { label: '\\%', detail: 'percent character', documentation: 'escapes the percent character', }, { label: '\\#', detail: 'hash character', documentation: 'escapes the hash character', }, { label: '\\(', detail: 'left parenthesis character', documentation: 'escapes the left parenthesis character', }, { label: '\\)', detail: 'right parenthesis character', documentation: 'escapes the right parenthesis character', }, { label: '\\{', detail: 'left curly bracket character', documentation: 'escapes the left curly bracket character', }, { label: '\\}', detail: 'right curly bracket character', documentation: 'escapes the right curly bracket character', }, { label: '\\[', detail: 'left bracket character', documentation: 'escapes the left bracket character', }, { label: '\\]', detail: 'right bracket character', documentation: 'escapes the right bracket character', }, { label: '\\<', detail: 'less than character', documentation: 'escapes the less than character', }, { label: '\\>', detail: 'greater than character', documentation: 'escapes the more than character', }, { label: '\\^', detail: 'circumflex character', documentation: 'escapes the circumflex character', }, { label: '\\&', detail: 'ampersand character', documentation: 'escapes the ampersand character', }, { label: '\\;', detail: 'semicolon character', documentation: 'escapes the semicolon character', }, { label: '\\"', detail: 'quote character', documentation: 'escapes the quote character', }, { label: "\\'", detail: 'quote character', documentation: 'escapes the apostrophe character', }, { label: '\\xxx', detail: 'hexadecimal character', documentation: 'where xx is a hexadecimal number, escapes the ascii character with the specified value. For example, \\x9 is the tab character.', }, { label: '\\Xxx', detail: 'hexadecimal character', documentation: 'where xx is a hexadecimal number, escapes a byte of data with the specified value. If you are using a mutibyte encoding, this can be used to enter invalid strings. Only use this if you know what you are doing.', }, { label: '\\ooo', detail: 'octal character', documentation: 'where ooo is an octal number, escapes the ascii character with the specified value. For example, \\011 is the tab character.', }, { label: '\\uxxxx', detail: 'unicode character', documentation: 'where xxxx is a hexadecimal number, escapes the 16-bit Unicode character with the specified value. For example, \\u9 is the tab character.', }, { label: '\\Uxxxxxxxx', detail: 'unicode character', documentation: 'where xxxxxxxx is a hexadecimal number, escapes the 32-bit Unicode character with the specified value. For example, \\U9 is the tab character.', }, { label: '\\cx', detail: 'alphabet character', documentation: ' where x is a letter of the alphabet, escapes the control sequence generated by pressing the control key and the specified letter. for example, \\ci is the tab character', }, ] as FishCompletionItem[]; const PrebuiltVars: FishCompletionItem[] = [ ...PrebuiltDocumentationMap.getByType('variable').map((item) => { return { label: item.name, detail: 'variable', kind: CompletionItemKind.Variable, documentation: item.description, useDocAsDetail: true, }; }) as FishCompletionItem[], ]; const PrebuiltFuncs: FishCompletionItem[] = [ ...PrebuiltDocumentationMap.getByType('command').map((item) => { return { label: item.name, detail: 'function', kind: CompletionItemKind.Function, documentation: item.description, useDocAsDetail: true, }; }) as FishCompletionItem[], ]; const Pipes: FishCompletionItem[] = [ { label: '<', detail: 'READ ', detail: 'WRITE >DESTINATION', insertText: '>', documentation: 'To write standard output to a file, use >DESTINATION', }, { label: '2>', detail: 'WRITE 2>DESTINATION', insertText: '2>', documentation: 'To write standard error to a file, use 2>DESTINATION', }, { label: '>>', detail: 'APPEND >>DESTINATION', insertText: '>>', documentation: 'To append standard output to a file, use >>DESTINATION', }, { label: '2>>', detail: 'APPEND 2>>DESTINATION', insertText: '2>>', documentation: 'To append standard error to a file, use 2>>DESTINATION', }, { label: '>?', detail: 'NOCLOBBER >? DESTINATION', insertText: '>?', documentation: 'To not overwrite (“clobber”) an existing file, use >?DESTINATION or 2>?DESTINATION. This is known as the “noclobber” redirection.', }, { label: '1>?', detail: 'NOCLOBBER 1>?DESTINATION', insertText: '1>?', documentation: 'To not overwrite (“clobber”) an existing file, use >?DESTINATION or 2>?DESTINATION. This is known as the “noclobber” redirection.', }, { label: '2>?', detail: 'NOCLOBBER 2>?DESTINATION', insertText: '2>?', documentation: 'To not overwrite (“clobber”) an existing file, use >?DESTINATION or 2>?DESTINATION. This is known as the “noclobber” redirection.', }, { label: '&-', detail: 'CLOSE &-', insertText: '&-', documentation: 'An ampersand followed by a minus sign (&-). The file descriptor will be closed.', }, { label: '|', detail: 'OUTPUT | INPUT', insertText: '|', documentation: 'Pipe one stream with another. Usually standard output of one command will be piped to standard input of another. OUTPUT | INPUT', }, { label: '&', detail: 'DISOWN &', insertText: '&', documentation: 'Disown output . OUTPUT &', }, { label: '&>', detail: 'STDOUT_AND_STDERR &>', insertText: '&>', documentation: 'the redirection &> can be used to direct both stdout and stderr to the same destination', }, { label: '&|', detail: 'STDOUT_AND_STDERR &|', insertText: '&|', documentation: 'the redirection &| can be used to direct both stdout and stderr to the same destination', }, ] as FishCompletionItem[]; const StatusNumbers: FishCompletionItem[] = [ { label: '0', detail: 'Status Success', documentation: 'Success exit status, generally means that the command executed successfully.', examples: [ CompletionExample.create('An implementation of the true command as a fish function:', 'function true', ' return 0', 'end', ), CompletionExample.create('Using true in an if statement', 'if true', ' echo "This will be printed"', 'end', 'if !true', ' echo "This will not be printed"', 'end', ), ], }, { label: '1', detail: 'Status Failure', documentation: 'Failure exit status, generally means that the command executed with an Error.', examples: [ CompletionExample.create('An implementation of the false command as a fish function:', 'function false', ' return 1', 'end', ), CompletionExample.create('Using false in an if statement', 'if false', ' echo "This will not be printed"', 'end', 'if !false', ' echo "This will be printed"', 'end', ), ], }, { label: '2', detail: 'Status Misuse', documentation: 'Misuse exit status, generally means that the command was used incorrectly.', examples: [ CompletionExample.create('as seen from the ls manpage:', 'ls /directory/nonexistent', 'echo $status # prints "2"', ), ], }, { label: '121', detail: 'Status Invalid Arguments', documentation: 'is generally the exit status of commands if they were supplied with invalid arguments.', }, { label: '123', detail: 'Status Invalid Command', documentation: 'means that the command was not executed because the command name contained invalid characters.', }, { label: '124', detail: 'Status No Matches', documentation: 'means that the command was not executed because none of the wildcards in the command produced any matches.', }, { label: '125', detail: 'Status Invalid Privileges', documentation: 'means that while an executable with the specified name was located, the operating system could not actually execute the command.', }, { label: '126', detail: 'Status Not Executable', documentation: 'means that while a file with the specified name was located, it was not executable.', }, { label: '127', detail: 'Status Not Found', documentation: 'means that no function, builtin or command with the given name could be located.', }, ] as FishCompletionItem[]; const StringRegex: FishCompletionItem[] = [ { label: '*', detail: '0 >= MATCHES', documentation: 'refers to 0 or more repetitions of the previous expression', insertText: '*', insertTextFormat: 1, examples: [], }, { label: '^', detail: 'START of string', documentation: '^ is the start of the string or line, $ the end', insertText: '^', }, { label: '$', detail: 'END of string', documentation: '$ the end of string or line', insertText: '$', }, { label: '+', detail: '1 >= MATCHES', documentation: '1 or more', insertText: '+', insertTextFormat: 1, examples: [], }, { label: '?', detail: '0 or 1 MATCHES', documentation: '0 or 1.', insertText: '?', examples: [], }, { label: '{n}', detail: 'exactly n MATCHES', documentation: 'to exactly n (where n is a number)', insertText: '{n}', examples: [], }, { label: '{n,m}', detail: 'n <= MATCHES <= m', documentation: 'at least n, no more than m.', insertText: '{${1:n},${2:m}}', examples: [], }, { label: '{n,}', detail: 'n >= MATCHES', documentation: 'n or more', insertText: '{${1:number},}', insertTextFormat: 2, examples: [], }, { label: '.', detail: 'Alpha-numeric Character', documentation: "'.' any character except newline", insertText: '.', examples: [], }, { label: '\\d a decimal digit', detail: 'Decimal Character', documentation: '\\d a decimal digit and \\D, not a decimal digit', insertText: '\\d', examples: [], }, { label: '\\D not a decimal digit', detail: 'Not a Decimal Character', documentation: '\\d a decimal digit and \\D, not a decimal digit', insertText: '\\D', examples: [], }, { label: '\\s whitespace', detail: 'Whitespace Character', documentation: 'whitespace and \\S, not whitespace ', insertText: '\\s', examples: [], }, { label: '\\S not whitespace', detail: 'Not a Whitespace Character', documentation: '\\S, not whitespace and \\s whitespace', insertText: '\\S', examples: [], }, { label: '\\w a “word” character', detail: 'Word Character', documentation: '\\w a “word” character and \\W, a “non-word” character ', insertText: '\\w', }, { label: '\\W a “non-word” character', detail: 'Non-Word Character', documentation: 'a “non-word” character ', insertText: '\\W', }, { label: '[...] a character set', detail: 'Character Set', documentation: '[...] - (where “…” is some characters) is a character set ', insertText: '[...]', }, { label: '[^...]', detail: 'Inverse Character Set', documentation: '[^...] is the inverse of the given character set', insertText: '[^...]', }, { label: '[x-y] the range of characters from x-y', detail: 'Range of Characters', documentation: '[x-y] is the range of characters from x-y', insertText: '[x-y]', }, { label: '[[:xxx:]]', detail: 'Named Character Set', documentation: '[[:xxx:]] is a named character set', insertText: '[[:xxx:]]', }, { label: '[[:^xxx:]]', detail: 'Inverse Named Character Set', documentation: '[[:^xxx:]] is the inverse of a named character set', insertText: '[[:^xxx:]]', }, { label: '[[:alnum:]]', detail: 'Alphanumeric Character', documentation: '[[:alnum:]] : “alphanumeric”', insertText: '[[:alnum:]]', }, { label: '[[:alpha:]]', detail: 'Alphabetic Character', documentation: '[[:alpha:]] : “alphabetic”', insertText: '[[:alpha:]]', }, { label: '[[:ascii:]]', detail: 'ASCII Character', documentation: '[[:ascii:]] : “0-127”', insertText: '[[:ascii:]]', }, { label: '[[:blank:]]', detail: 'Space or Tab', documentation: '[[:blank:]] : “space or tab”', insertText: '[[:blank:]]', }, { label: '[[:cntrl:]]', detail: 'Control Character', documentation: '[[:cntrl:]] : “control character”', insertText: '[[:cntrl:]]', }, { label: '[[:digit:]]', detail: 'Decimal Digit', documentation: '[[:digit:]] : “decimal digit”', insertText: '[[:digit:]]', }, { label: '[[:graph:]]', detail: 'Printing Character', documentation: '[[:graph:]] : “printing, excluding space”', insertText: '[[:graph:]]', }, { label: '[[:lower:]]', detail: 'Lower Case Letter', documentation: '[[:lower:]] : “lower case letter”', insertText: '[[:lower:]]', }, { label: '[[:print:]]', detail: 'Printing Character', documentation: '[[:print:]] : “printing, including space”', insertText: '[[:print:]]', }, { label: '[[:punct:]]', detail: 'Punctuation Character', documentation: '[[:punct:]] : “printing, excluding alphanumeric”', insertText: '[[:punct:]]', }, { label: '[[:space:]]', detail: 'White Space Character', documentation: '[[:space:]] : “white space”', insertText: '[[:space:]]', }, { label: '[[:upper:]]', detail: 'Upper Case Letter', documentation: '[[:upper:]] : “upper case letter”', insertText: '[[:upper:]]', }, { label: '[[:word:]]', detail: 'Word Character', documentation: '[[:word:]] : “same as w”', insertText: '[[:word:]]', }, { label: '[[:xdigit:]]', detail: 'Hexadecimal Digit', documentation: '[[:xdigit:]] : “hexadecimal digit”', insertText: '[[:xdigit:]]', }, { label: '(...)', detail: 'Capturing Group', documentation: '(...) is a capturing group', insertText: '(...)', }, { label: '(?:...) is a non-capturing group', detail: 'Non-Capturing Group', documentation: '(?:...) is a non-capturing group', insertText: '(?:...)', }, { label: '\\n', detail: 'Backreference', documentation: '\\n is a backreference (where n is the number of the group, starting with 1)', insertText: '\\', }, { label: '$n', detail: 'Reference', documentation: '$n is a reference from the replacement expression to a group in the match expression.', insertText: '$', }, { label: '\\b', detail: 'Word Boundary', documentation: '\\b denotes a word boundary, \\B is not a word boundary.', insertText: '\\b', }, { label: '|', detail: 'Alternation', documentation: '| is “alternation”, i.e. the “or”.', insertText: '|', }, ] as FishCompletionItem[]; const FormatStrings: FishCompletionItem[] = [ { label: '%d', detail: 'Decimal Integer', documentation: 'Argument will be used as decimal integer (signed or unsigned)', }, { label: '%i', detail: 'Decimal Integer', documentation: 'Argument will be used as decimal integer (signed or unsigned)', }, { label: '%o', detail: 'Octal Integer', documentation: 'An octal unsigned integer', }, { label: '%u', detail: 'Unsigned Integer', documentation: 'An unsigned decimal integer - this means negative numbers will wrap around', }, { label: '%x', detail: 'Hexadecimal Integer', documentation: 'An unsigned hexadecimal integer', }, { label: '%X', detail: 'Hexadecimal Integer', documentation: 'An unsigned hexadecimal integer', }, { label: '%f', detail: 'Floating Point', documentation: 'A floating-point number. %f defaults to 6 places after the decimal point (which is locale-dependent - e.g. in de_DE it will be a ,).', }, { label: '%g', detail: 'Floating Point', documentation: 'will trim trailing zeroes and switch to scientific notation (like %e) if the numbers get small or large enough.', }, { label: '%G', detail: 'Floating Point', documentation: 'will trim trailing zeroes and switch to scientific notation (like %e) if the numbers get small or large enough.', }, { label: '%s', detail: 'String', documentation: 'A string', }, { label: '%b', detail: 'Word Boundary', documentation: 'As a string, interpreting backslash escapes, except that octal escapes are of the form 0 or 0ooo.', }, { label: '%%', detail: 'Literal Percent', documentation: 'Signifies a literal "%"', }, ] as FishCompletionItem[]; const Combiners: FishCompletionItem[] = [ { label: 'and', detail: 'and CONDITION; COMMANDS; end', documentation: 'is a combiner that combines two commands with a logical and. The second command is only executed if the first command returns true.', }, { label: 'or', detail: 'or CONDITION; COMMANDS; end', documentation: 'is a combiner that combines two commands with a logical or. The second command is only executed if the first command returns false.', }, { label: 'not', detail: 'not CONDITION; COMMANDS; end', documentation: 'not negates the exit status of another command. If the exit status is zero, not returns 1. Otherwise, not returns 0.', }, { label: '||', detail: '|| CONDITION; COMMANDS; end', documentation: 'is a combiner that combines two commands with a logical or. The second command is only executed if the first command returns false.', }, { label: '&&', detail: '&& CONDITION; COMMANDS; end', documentation: 'is a combiner that combines two commands with a logical and. The second command is only executed if the first command returns true.', }, { label: '!', detail: '! CONDITION; COMMANDS; end', documentation: 'not negates the exit status of another command. If the exit status is zero, not returns 1. Otherwise, not returns 0.', }, ] as FishCompletionItem[]; const Statements: FishCompletionItem[] = [ { label: 'if', detail: 'if CONDITION; COMMANDS; end', documentation: 'if is a conditional statement that executes a command if a condition is true.', }, { label: 'else if', detail: 'else if CONDITION; COMMANDS; end', documentation: 'else if is a conditional statement that executes a command if a condition is true.', }, { label: 'else', detail: 'else; COMMANDS; end', documentation: 'else is a conditional statement that executes a command if a condition is true.', }, { label: 'switch', detail: 'switch CONDITION; case VALUE; COMMANDS; end; end', documentation: 'switch is a conditional statement that executes a command if a condition is true.', }, { label: 'while', detail: 'while CONDITION; COMMANDS; end', documentation: 'while is a conditional statement that executes a command if a condition is true. (Works like a repeated "if" statement)', }, ] as FishCompletionItem[]; const shebangs = [ { label: '#!/usr/bin/env fish', fishKind: 'shebang', detail: 'execute script using fish env', documentation: 'execute script using fish env', }, { label: '#!/usr/local/bin/fish', fishKind: 'shebang', detail: '#!/usr/local/bin/fish', documentation: 'Check this path exists. Could be /usr/bin/fish, /usr/local/bin/fish, or /bin/fish', }, { label: '#!/usr/bin/fish', fishKind: 'shebang', detail: '#!/usr/bin/fish', documentation: 'Check this path exists. Could be /usr/bin/fish, /usr/local/bin/fish, or /bin/fish', }, { label: '#!/bin/fish', fishKind: 'shebang', detail: '#!/bin/fish', documentation: 'Check this path exists. Could be /usr/bin/fish, /usr/local/bin/fish, or /bin/fish', }, ] as FishCompletionItem[]; const disableDiagnostics = Object.values(ErrorCodes.codes).map((diagnostic) => { return { label: `${diagnostic.code}`, detail: diagnostic.message, useDocAsDetail: true, documentation: [ `# Error code: ${diagnostic.code}`, md.separator(), `${diagnostic.message}`, md.separator(), `DIAGNOSTIC LEVEL: ${ErrorCodes.getSeverityString(diagnostic.severity)}`, ].join('\n'), insertText: `${diagnostic.code}`, }; }) as FishCompletionItem[]; const comments = [ { label: '# @fish-lsp-disable', detail: 'Disable all LSP diagnostics for this file', fishKind: FishCompletionItemKind.COMMENT, documentation: [ '# Disable all LSP diagnostics for this file', md.separator(), 'This directive will disable all diagnostics for this file. This is useful when you want to ignore all diagnostics for a file.', '___', '```fish', '# @fish-lsp-disable', 'alias ls "ls -l" # no more warnings', '```', '```fish', '# @fish-lsp-disable 2002', 'alias ls="ls -l" # no more warnings', '# @fish-lsp-enable', 'alias ls="ls -l" # warnings enabled again', '```', ].join('\n'), }, { label: '# @fish-lsp-enable', detail: 'Enable all LSP diagnostics for this file', kind: CompletionItemKind.Enum, fishKind: FishCompletionItemKind.COMMENT, documentation: [ '# Enables all LSP diagnostics for this file', md.separator(), 'This directive will enable all diagnostics for this file. This is useful when you want to turn diagnostics on & off in sections.', '___', '```fish', '# @fish-lsp-disable', 'alias ls "ls -l" # no more warnings', '```', '```fish', '# @fish-lsp-disable 2002', 'alias ls="ls -l" # no more warnings', '# @fish-lsp-enable', 'alias ls="ls -l" # warnings enabled again', '```', ].join('\n'), }, { label: '# @fish-lsp-disable-next-line', detail: 'Disables all LSP diagnostics for the next line', fishKind: FishCompletionItemKind.COMMENT, documentation: [ '# Disables LSP diagnostics for the next line.', md.separator(), ' Any enabled diagnostics inside the file, before this comment will be enabled again after this line.', '___', '```fish', '# @fish-lsp-disable-next-line', 'alias ls "ls -a" # no more warnings', '```', '```fish', '# @fish-lsp-disable-next-line 2002', 'alias ls="ls -1" # no more warnings', 'alias lsl="ls -l" # warnings enabled again', '```', ].join('\n'), }, { label: '# @fish-lsp-enable-next-line', detail: 'Enable LSP diagnostics for next line', fishKind: FishCompletionItemKind.COMMENT, documentation: [ '# Enables LSP diagnostics for next line', md.separator(), 'This directive will enable all diagnostics for the next line.', '___', '```fish', '# @fish-lsp-disable', 'alias ls "ls -a" # warnings are disabled in this file', '# @fish-lsp-enable-next-line', 'alias lsl="ls -l" # warnings temporarily enabled again', 'alias lss "ls -s" # no more warnings again', '```', ].join('\n'), }, ] as FishCompletionItem[]; export const StaticItems = { [FishCompletionItemKind.ESC_CHARS]: EscapedChars, [FishCompletionItemKind.PIPE]: Pipes, [FishCompletionItemKind.STATUS]: StatusNumbers, [FishCompletionItemKind.REGEX]: StringRegex, [FishCompletionItemKind.FORMAT_STR]: FormatStrings, [FishCompletionItemKind.COMBINER]: Combiners, [FishCompletionItemKind.STATEMENT]: Statements, [FishCompletionItemKind.SHEBANG]: shebangs, [FishCompletionItemKind.COMMENT]: comments, [FishCompletionItemKind.DIAGNOSTIC]: disableDiagnostics, [FishCompletionItemKind.VARIABLE]: PrebuiltVars, [FishCompletionItemKind.FUNCTION]: PrebuiltFuncs, }; ================================================ FILE: src/utils/completion/types.ts ================================================ import { CompletionContext, CompletionItem, CompletionItemKind, MarkupContent, Position, Range, SymbolKind, TextEdit, } from 'vscode-languageserver'; import { FishSymbol } from '../../parsing/symbol'; export const FishCompletionItemKind = { ABBR: 'abbr', BUILTIN: 'builtin', FUNCTION: 'function', VARIABLE: 'variable', EVENT: 'event', PIPE: 'pipe', ESC_CHARS: 'esc_chars', STATUS: 'status', WILDCARD: 'wildcard', COMMAND: 'command', ALIAS: 'alias', REGEX: 'regex', COMBINER: 'combiner', FORMAT_STR: 'format_str', STATEMENT: 'statement', ARGUMENT: 'argument', PATH: 'path', EMPTY: 'empty', SHEBANG: 'shebang', COMMENT: 'comment', DIAGNOSTIC: 'diagnostic', } as const; export type FishCompletionItemKind = typeof FishCompletionItemKind[keyof typeof FishCompletionItemKind]; export const toCompletionItemKind: Record = { [FishCompletionItemKind.ABBR]: CompletionItemKind.Snippet, [FishCompletionItemKind.BUILTIN]: CompletionItemKind.Keyword, [FishCompletionItemKind.FUNCTION]: CompletionItemKind.Function, [FishCompletionItemKind.VARIABLE]: CompletionItemKind.Variable, [FishCompletionItemKind.EVENT]: CompletionItemKind.Event, [FishCompletionItemKind.PIPE]: CompletionItemKind.Operator, [FishCompletionItemKind.ESC_CHARS]: CompletionItemKind.Operator, [FishCompletionItemKind.STATUS]: CompletionItemKind.EnumMember, [FishCompletionItemKind.WILDCARD]: CompletionItemKind.Operator, [FishCompletionItemKind.COMMAND]: CompletionItemKind.Class, [FishCompletionItemKind.ALIAS]: CompletionItemKind.Constructor, [FishCompletionItemKind.REGEX]: CompletionItemKind.Operator, [FishCompletionItemKind.COMBINER]: CompletionItemKind.Keyword, [FishCompletionItemKind.FORMAT_STR]: CompletionItemKind.Operator, [FishCompletionItemKind.STATEMENT]: CompletionItemKind.Keyword, [FishCompletionItemKind.ARGUMENT]: CompletionItemKind.Property, [FishCompletionItemKind.PATH]: CompletionItemKind.File, [FishCompletionItemKind.EMPTY]: CompletionItemKind.Text, [FishCompletionItemKind.SHEBANG]: CompletionItemKind.File, [FishCompletionItemKind.COMMENT]: CompletionItemKind.Text, [FishCompletionItemKind.DIAGNOSTIC]: CompletionItemKind.Text, }; export type FishCompletionData = { uri: string; line: string; word: string; position: Position; command?: string; context?: CompletionContext; }; export interface FishCompletionItem extends CompletionItem { detail: string; //documentation: string; fishKind: FishCompletionItemKind; examples?: CompletionExample[]; local: boolean; useDocAsDetail: boolean; data?: FishCompletionData; priority?: number; setKinds(kind: FishCompletionItemKind): FishCompletionItem; setLocal(): FishCompletionItem; setData(data: FishCompletionData): FishCompletionItem; setPriority(priority: number): FishCompletionItem; } export class FishCompletionItem implements FishCompletionItem { constructor( public label: string, public fishKind: FishCompletionItemKind, public detail: string, public documentation: string | MarkupContent, public examples?: CompletionExample[], ) { this.local = false; this.useDocAsDetail = false; //this.labelDetails = this.detail; this.setKinds(fishKind); } setKinds(kind: FishCompletionItemKind) { this.kind = toCompletionItemKind[kind]; this.fishKind = kind; return this; } setLocal() { this.local = true; return this; } setUseDocAsDetail() { this.useDocAsDetail = true; return this; } setData(data: FishCompletionData) { this.data = data; const removeLength = data.word ? data.word.length : 1; this.textEdit = TextEdit.replace( Range.create({ line: data.position.line, character: data.position.character - removeLength }, data.position), this.insertText || this.label, ); return this; } setPriority(priority: number) { this.priority = priority; return this; } } export class FishCommandCompletionItem extends FishCompletionItem { // constructor(label: string, fishKind: FishCompletionItemKind, detail: string, documentation: string) { // super(label, fishKind, detail, documentation); // } } export class FishAbbrCompletionItem extends FishCommandCompletionItem { constructor(label: string, detail: string, documentation: string) { super(label, FishCompletionItemKind.ABBR, detail, documentation); const last = Math.max(documentation.lastIndexOf('#') + 1, documentation.length); this.insertText = documentation.slice(label.length + 1, last); this.commitCharacters = ['\t', ';', ' ']; } } export class FishAliasCompletionItem extends FishCommandCompletionItem { constructor(label: string, detail: string, documentation: string) { super(label, FishCompletionItemKind.ALIAS, detail, documentation); this.documentation = documentation.slice(label.length + 1); } } export namespace FishCompletionItem { export function create(label: string, kind: FishCompletionItemKind, detail: string, documentation: string, examples?: CompletionExample[]) { switch (kind) { case FishCompletionItemKind.ABBR: return new FishAbbrCompletionItem(label, detail, documentation); case FishCompletionItemKind.ALIAS: return new FishAliasCompletionItem(label, detail, documentation); case FishCompletionItemKind.COMMAND: case FishCompletionItemKind.BUILTIN: case FishCompletionItemKind.FUNCTION: case FishCompletionItemKind.VARIABLE: case FishCompletionItemKind.EVENT: return new FishCommandCompletionItem(label, kind, detail, documentation); default: return new FishCompletionItem(label, kind, detail, documentation, examples); } } export function fromSymbol(symbol: FishSymbol) { switch (symbol.kind) { case SymbolKind.Function: return create(symbol.name, FishCompletionItemKind.FUNCTION, 'Function', symbol.detail).setLocal().setPriority(50); case SymbolKind.Variable: return create(symbol.name, FishCompletionItemKind.VARIABLE, 'Variable', symbol.detail).setLocal().setPriority(60); default: return create(symbol.name, FishCompletionItemKind.EMPTY, 'Empty', symbol.detail).setLocal().setPriority(70); } } export function createData( uri: string, line: string, word: string, position: Position, command?: string, context?: CompletionContext, ): FishCompletionData { return { uri, line, word, position, command, context }; } } export interface CompletionExample { title: string; shellText: string; } export namespace CompletionExample { export function create(title: string, ...shellText: string[]): CompletionExample { const shellTextString: string = shellText.length > 1 ? shellText.join('\n') : shellText.at(0)!; return { title, shellText: shellTextString, }; } export function toMarkedString(example: CompletionExample): string { return [ '___', '```fish', `# ${example.title}`, example.shellText, '```', ].join('\n'); } } ================================================ FILE: src/utils/definition-scope.ts ================================================ import { SyntaxNode } from 'web-tree-sitter'; import * as NodeTypes from './node-types'; import { isAutoloadedUriLoadsAliasName, isAutoloadedUriLoadsFunctionName } from './translation'; import { firstAncestorMatch, getRange, isPositionWithinRange, getParentNodes } from './tree-sitter'; import { Position } from 'vscode-languageserver'; import { LspDocument } from '../document'; export type ScopeTag = 'global' | 'universal' | 'local' | 'function' | 'inherit'; export interface DefinitionScope { uri: string; scopeNode: SyntaxNode; scopeTag: ScopeTag; } export class DefinitionScope { constructor( public scopeNode: SyntaxNode, public scopeTag: ScopeTag) { } static create( scopeNode: SyntaxNode, scopeTag: 'global' | 'universal' | 'local' | 'function' | 'inherit', ): DefinitionScope { return new DefinitionScope(scopeNode, scopeTag); } /** * Add checks for issue mentioned at: https://github.com/ndonfris/fish-lsp/issues/96 */ containsPosition(position: Position) { // Global and universal scopes are always considered to contain all positions if (this.tag >= DefinitionScope.ScopeTags.global) return true; // If there is no scope node, we cannot determine containment if (!this.scopeNode) return false; // Check if the position is within the range of the scope node return isPositionWithinRange(position, getRange(this.scopeNode)); } isBeforePosition(position: Position) { return this.scopeNode.startPosition.row < position.line || this.scopeNode.startPosition.row === position.line && this.scopeNode.startPosition.column < position.character; } isAfterPosition(position: Position) { return this.scopeNode.endPosition.row > position.line || this.scopeNode.endPosition.row === position.line && this.scopeNode.endPosition.column > position.character; } isBeforeNode(node: SyntaxNode) { const range = getRange(node); return this.scopeNode.startPosition.row < range.start.line || this.scopeNode.startPosition.row === range.start.line && this.scopeNode.startPosition.column < range.start.character; } isAfterNode(node: SyntaxNode) { const range = getRange(node); return this.scopeNode.endPosition.row > range.end.line || this.scopeNode.endPosition.row === range.end.line && this.scopeNode.endPosition.column > range.end.character; } containsNode(node: SyntaxNode) { const range = getRange(node); return this.containsPosition(range.start); } get tag() { const tag = this.scopeTag; return DefinitionScope.ScopeTags[tag] || 0; } static get ScopeTags() { return { universal: 5, global: 4, function: 3, local: 2, inherit: 1, '': 0, } as const; } } export class VariableDefinitionFlag { public short: string; public long: string; constructor(short: string, long: string) { this.short = short; this.long = long; } isMatch(node: SyntaxNode) { if (!NodeTypes.isOption(node)) { return false; } if (NodeTypes.isShortOption(node)) { return node.text.slice(1).split('').includes(this.short); } if (NodeTypes.isLongOption(node)) { return node.text.slice(2) === this.long; } return false; } get kind() { return this.long; } } const variableDefinitionFlags = [ new VariableDefinitionFlag('g', 'global'), new VariableDefinitionFlag('l', 'local'), new VariableDefinitionFlag('', 'inherit'), //new VariableDefinitionFlag('x', 'export'), new VariableDefinitionFlag('f', 'function'), new VariableDefinitionFlag('U', 'universal'), ]; const hasParentFunction = (node: SyntaxNode) => { return !!firstAncestorMatch(node, NodeTypes.isFunctionDefinition); }; function getMatchingFlags(focusedNode: SyntaxNode, nodes: SyntaxNode[]) { for (const node of nodes) { const match = variableDefinitionFlags.find(flag => flag.isMatch(node)); if (match) { return match; } } return hasParentFunction(focusedNode) ? new VariableDefinitionFlag('f', 'function') : new VariableDefinitionFlag('', 'inherit'); } function findScopeFromFlag(node: SyntaxNode, flag: VariableDefinitionFlag) { let scopeNode: SyntaxNode | null = node.parent!; let scopeFlag = flag.kind; switch (flag.kind) { case 'global': scopeNode = firstAncestorMatch(node, NodeTypes.isProgram); scopeFlag = 'global'; break; case 'universal': scopeNode = firstAncestorMatch(node, NodeTypes.isProgram); scopeFlag = 'universal'; break; case 'local': scopeNode = firstAncestorMatch(node, NodeTypes.isScope); //scopeFlag = 'local' break; case 'function': scopeNode = firstAncestorMatch(node, NodeTypes.isFunctionDefinition); scopeFlag = 'function'; break; case 'for_scope': scopeNode = firstAncestorMatch(node, NodeTypes.isFunctionDefinition); scopeFlag = 'function'; if (!scopeNode) { scopeNode = firstAncestorMatch(node, NodeTypes.isProgram); scopeFlag = 'global'; } break; case 'inherit': scopeNode = firstAncestorMatch(node, NodeTypes.isScope); scopeFlag = 'inherit'; break; default: scopeNode = firstAncestorMatch(node, NodeTypes.isScope); //scopeFlag = 'local' break; } const finalScopeNode = scopeNode || node.parent!; return DefinitionScope.create(finalScopeNode, scopeFlag as ScopeTag); } export function getVariableScope(node: SyntaxNode) { const definitionNodes: SyntaxNode[] = expandEntireVariableLine(node); const keywordNode = definitionNodes[0]!; let matchingFlag = null; switch (keywordNode.text) { case 'for': matchingFlag = new VariableDefinitionFlag('', 'for_scope'); break; case 'set': case 'read': case 'function': default: matchingFlag = getMatchingFlags(node, definitionNodes); break; } const scope = findScopeFromFlag(node, matchingFlag); return scope; } export function getScope(document: LspDocument, node: SyntaxNode) { if (NodeTypes.isEmittedEventDefinitionName(node)) { return DefinitionScope.create(node, 'global')!; } if (NodeTypes.isAliasDefinitionName(node)) { const isAutoloadedName = isAutoloadedUriLoadsAliasName(document); if (isAutoloadedName(node)) { return DefinitionScope.create(node, 'global')!; } const parents = getParentNodes(node.parent!.parent!) || getParentNodes(node.parent!); const firstParent = parents .filter(n => NodeTypes.isProgram(n) || NodeTypes.isFunctionDefinition(n)) .at(0)!; return DefinitionScope.create(firstParent, 'local')!; } else if (NodeTypes.isFunctionDefinitionName(node)) { const isAutoloadedName = isAutoloadedUriLoadsFunctionName(document); // gets from ~/.config/fish/functions/.fish // const loadedName = pathToRelativeFunctionName(uri); // we know node.parent must exist because a isFunctionDefinitionName() must have // a isFunctionDefinition() parent node. We know there must be atleast one parent // because isProgram() is a valid parent node. const parents = getParentNodes(node.parent!.parent!) || getParentNodes(node.parent!); const firstParent = parents .filter(n => NodeTypes.isProgram(n) || NodeTypes.isFunctionDefinition(n)) .at(0)!; // if the function name is autoloaded or in config.fish if (isAutoloadedName(node)) { const program = firstAncestorMatch(node, NodeTypes.isProgram)!; return DefinitionScope.create(program, 'global')!; } return DefinitionScope.create(firstParent, 'local')!; } else if (NodeTypes.isVariableDefinitionName(node)) { return getVariableScope(node); } // should not ever happen with current LSP implementation const scope = firstAncestorMatch(node, NodeTypes.isScope)!; return DefinitionScope.create(scope, 'local'); } export function expandEntireVariableLine(node: SyntaxNode): SyntaxNode[] { const results: SyntaxNode[] = [node]; let current = node.previousSibling; while (current !== null) { if (!current || NodeTypes.isNewline(current)) { break; } results.unshift(current); current = current.previousSibling; } current = node.nextSibling; while (current !== null) { if (!current || NodeTypes.isNewline(current)) { break; } results.push(current); current = current.nextSibling; } return results; } export function setQuery(searchNodes: SyntaxNode[]) { const queryFlag = new VariableDefinitionFlag('q', 'query'); for (const flag of searchNodes) { if (queryFlag.isMatch(flag)) { return true; } } return false; } ================================================ FILE: src/utils/documentation-cache.ts ================================================ import { SymbolKind, MarkupContent } from 'vscode-languageserver'; import { execCmd, execCommandDocs, execEscapedCommand } from './exec'; import { FishCompletionItem, CompletionExample } from './completion/types'; import { isBuiltin } from './builtins'; /**************************************************************************************** * * * @TODO: DO NOT convert this to a FishDocumentSymbol! Instead, use this to cache to * * FishDocumentSymbol documentation strings cached. FishDocumentSymbol will lookup * * base documentation from this cache. Converting this to a FishDocumentSymbol will * * cause issues with the lsp api because, documentSymbols require a range/location * * (Maybe check BaseSymbol, I vaguely remember that one of the Symbol's * * mentions not requiring a Range, having multiple symbols is still * * not a capability the protocol supports, as per the v.0.7.0) * * With that in mind, build out a structure inside analyzer, that will be able to use * * everything that is necessary for a well-informed detail to the client. * * Current goal likely needs: * * • parser * * • FishDocumentSymbol * * • This DocumentationCache * * • some kind of flag resolver (the function flags '--description', * * '--argument-names', '--inherit-variables', come to mind) * * * * * * @TODO: support docs & formatted docs. (non-markdown version will be docs) * * * * @TODO: Refactor building documentation string! Potentially remove documentation.ts * * * ****************************************************************************************/ export interface CachedGlobalItem { docs?: string; formattedDocs?: MarkupContent; uri?: string; referenceUris: Set; type: SymbolKind; resolved: boolean; } export function createCachedItem(type: SymbolKind, uri?: string): CachedGlobalItem { return { type: type, resolved: false, uri: uri, referenceUris: uri ? new Set([...uri]) : new Set(), } as CachedGlobalItem; } /** * Currently spoofs docs as FormattedDocs, likely to change in future versions. */ async function getNewDocString(name: string, item: CachedGlobalItem): Promise { switch (item.type) { case SymbolKind.Variable: return await getVariableDocString(name); case SymbolKind.Function: return await getFunctionDocString(name); case SymbolKind.Class: return await getBuiltinDocString(name); default: return undefined; } } export async function resolveItem(name: string, item: CachedGlobalItem, uri?: string) { if (uri !== undefined) { item.referenceUris.add(uri); } if (item.resolved) { return item; } if (item.type === SymbolKind.Function) { item.uri = await getFunctionUri(name); } const newDocStr: string | undefined = await getNewDocString(name, item); item.resolved = true; if (!newDocStr) { return item; } item.docs = newDocStr; return item; } /** * just a getter for the absolute path to a function defined */ async function getFunctionUri(name: string): Promise { const uriString = await execEscapedCommand(`type -ap ${name}`); const uri = uriString.join('\n').trim(); if (!uri) { return undefined; } return uri; } /** * builds MarkupString for function names, since fish shell standard for private functions * is naming convention with leading '__', this function ensures that our MarkupStrings * will be able to display the FunctionName (instead of interpreting it as '__' bold text) */ function _escapePathStr(functionTitleLine: string): string { const afterComment = functionTitleLine.split(' ').slice(1); const pathIndex = afterComment.findIndex((str: string) => str.includes('/')); const path: string = afterComment[pathIndex]?.toString() || ''; return [ '**' + afterComment.slice(0, pathIndex).join(' ').trim() + '**', `*\`${path}\`*`, '**' + afterComment.slice(pathIndex + 1).join(' ').trim() + '**', ].join(' '); } function _ensureMinLength(arr: T[], minLength: number, fillValue?: T): T[] { while (arr.length < minLength) { arr.push(fillValue as T); } return arr; } /** * builds FunctionDocumentation string */ export async function getFunctionDocString(name: string): Promise { const functionDoc = await execCmd(`functions ${name}`); const title = `___(function)___ - _${name}_`; if (!functionDoc) return; return [ title, '___', '```fish', functionDoc.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; } 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 { if (!isBuiltin(name)) return undefined; const cmdDocs: string = await execCommandDocs(name); if (!cmdDocs) { return undefined; } const splitDocs = cmdDocs.split('\n'); const startIndex = splitDocs.findIndex((line: string) => line.trim() === 'NAME'); const resultDocs = splitDocs.slice(startIndex).length > 3 ? splitDocs.slice(startIndex).join('\n') : splitDocs.join('\n'); return [ `__${name.toUpperCase()}__ - _https://fishshell.com/docs/current/cmds/${name.trim()}.html_`, '___', '```man', resultDocs, '```', ].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 ? [ first, '___', middle.join('\n'), '___', last, ].join('\n') : undefined; } export async function getCommandDocString(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 [ '```man', splitDocs.slice(startIndex).join('\n'), '```', ].join('\n'); } export function initializeMap(collection: string[], type: SymbolKind, _uri?: string): Map { const items: Map = new Map(); collection.forEach((item) => { items.set(item, createCachedItem(type)); }); return items; } export const extraBuiltins: string[] = [ 'export', ]; /** * Uses internal fish shell commands to store brief output for global variables, functions, * builtins, and unknown identifiers. This class is meant to be initialized once, on server * startup. It is then used as fallback documentation provider, if our analysis can't * resolve any documentation for a given identifier. */ export class DocumentationCache { private _variables: Map = new Map(); private _functions: Map = new Map(); private _builtins: Map = new Map(); private _unknowns: Map = new Map(); get items(): string[] { return [ ...this._variables.keys(), ...this._functions.keys(), ...this._builtins.keys(), ...this._unknowns.keys(), ]; } async parse(uri?: string) { this._unknowns = initializeMap([], SymbolKind.Null, uri); await Promise.all([ execEscapedCommand('set -n'), execEscapedCommand('functions -an | string collect'), execEscapedCommand('builtin -n'), ]).then(([vars, funcs, builtins]) => { this._variables = initializeMap(vars, SymbolKind.Variable, uri); this._functions = initializeMap(funcs, SymbolKind.Function, uri); this._builtins = initializeMap(builtins, SymbolKind.Class, uri); }); // add the extra builtins extraBuiltins.forEach((builtin) => { this._builtins.set(builtin, createCachedItem(SymbolKind.Class)); }); return this; } find(name: string, type?: SymbolKind): CachedGlobalItem | undefined { if (type === SymbolKind.Variable) { return this._variables.get(name); } if (type === SymbolKind.Function) { return this._functions.get(name); } if (type === SymbolKind.Class) { return this._builtins.get(name); } return this._unknowns.get(name); } findType(name: string): SymbolKind { if (this._variables.has(name)) { return SymbolKind.Variable; } if (this._functions.has(name)) { return SymbolKind.Function; } if (this._builtins.has(name)) { return SymbolKind.Class; } return SymbolKind.Null; } /** * @async * Resolves a symbol's documentation. Store's resolved items in the Cache, otherwise * returns the already cached item. */ async resolve(name: string, uri?: string, type?: SymbolKind) { const itemType = type || this.findType(name); let item: CachedGlobalItem | undefined = this.find(name, itemType); if (!item) { item = createCachedItem(itemType, uri); this._unknowns.set(name, item); } if (item.resolved && item.docs) { return item; } if (!item.resolved) { item = await resolveItem(name, item); } if (!item.docs) { this._unknowns.set(name, item); } this.setItem(name, item); return item; } /** * sets an item, mostly called within this class, because CachedGlobalItem will typically * already be resolved. * * @param {string} name - string for the symbol * @param {CachedGlobalItem} item - the item to set */ setItem(name: string, item: CachedGlobalItem) { switch (item.type) { case SymbolKind.Variable: this._variables.set(name, item); break; case SymbolKind.Function: this._functions.set(name, item); break; case SymbolKind.Class: this._builtins.set(name, item); break; default: this._unknowns.set(name, item); break; } } /** * getter for a cached item, guarding SymbolKind.Null from retrieved. */ getItem(name: string) { const item = this.find(name); if (!item || item.type === SymbolKind.Null) { return undefined; } return item; } } /** * Function to be called when the server is initialized, so that the DocumentationCache * can be populated. */ export async function initializeDocumentationCache() { const cache = new DocumentationCache(); await cache.parse(); return cache; } ================================================ FILE: src/utils/env-manager.ts ================================================ import path from 'path'; import { Config } from '../config'; import { AutoloadedPathVariables } from './process-env'; import fs from 'fs'; export function allPossibleAutoloadedFunctionPaths(functionName: string): string[] { const files: string[] = []; const file = `${functionName}.fish`; env.getAsArray('__fish_user_data_dir').forEach(p => { files.push(path.join(p, 'functions', file)); }); env.getAsArray('__fish_data_dir').forEach(p => { files.push(path.join(p, 'functions', file)); }); env.getAsArray('__fish_sysconfdir').forEach(p => { files.push(path.join(p, 'functions', file)); }); env.getAsArray('__fish_sysconf_dir').forEach(p => { files.push(path.join(p, 'functions', file)); }); env.getAsArray('__fish_vendor_functionsdirs').forEach(p => { files.push(path.join(p, file)); }); env.getAsArray('__fish_added_user_paths').forEach(p => { files.push(path.join(p, 'functions', file)); files.push(path.join(p, file)); }); env.getAsArray('fish_function_path').forEach(p => { files.push(path.join(p, file)); }); env.getAsArray('__fish_config_dir').forEach(p => { files.push(path.join(p, 'functions', file)); }); return files; } /** * Parses fish shell variable strings into arrays based on their format */ class FishVariableParser { /** * Main parse method that detects and handles different formats */ static parse(value: string): string[] { if (!value || value.trim() === '') return []; // Check if this is a PATH-like variable (contains colons) if (value.includes(':') && !value.includes(' ')) { return this.parsePathVariable(value); } // Otherwise parse as a space-separated variable possibly with quotes return this.parseSpaceSeparatedWithQuotes(value); } /** * Parse colon-separated path variables * Example: "/path/bin:/path/to/bin:/usr/share/bin" */ static parsePathVariable(value: string): string[] { return value.split(':').filter(Boolean); } /** * Parse space-separated values with respect for quotes * Handles both single and double quotes */ static parseSpaceSeparatedWithQuotes(value: string): string[] { const result: string[] = []; let currentToken = ''; let inSingleQuote = false; let inDoubleQuote = false; let wasEscaped = false; for (let i = 0; i < value.length; i++) { const char = value[i]; // Handle escape character if (char === '\\' && !wasEscaped) { wasEscaped = true; continue; } // Handle quotes if (char === "'" && !wasEscaped && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue; } if (char === '"' && !wasEscaped && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue; } // Handle spaces - only split on spaces outside of quotes if (char === ' ' && !inSingleQuote && !inDoubleQuote && !wasEscaped) { if (currentToken) { result.push(currentToken); currentToken = ''; } continue; } // Add the character to the current token currentToken += char; wasEscaped = false; } // Add the last token if there is one if (currentToken) { result.push(currentToken); } return result; } /** * Special method for indexing fish arrays (1-based indexing) */ static getAtIndex(array: string[], index: number): string | undefined { // Fish uses 1-based indexing if (index < 1) return undefined; return array[index - 1]; } static tokenSeparator(value: string): ':' | ' ' { if (value.includes(':') && !value.includes(' ')) { return ':'; } return ' '; } } export class EnvManager { private static instance: EnvManager; private envStore: Record = {}; /** * Keys that are present in the process.env */ public processEnvKeys: Set = new Set(Object.keys(process.env)); /** * Keys that are autoloaded by fish shell */ public autoloadedKeys: Set = new Set(AutoloadedPathVariables.all()); private allKeys: Set = new Set(); private constructor() { // Add all keys to the set this.setAllKeys(); // Clone initial environment Object.assign(this.envStore, process.env); } private setAllKeys(): void { this.allKeys = new Set([ ...this.getProcessEnvKeys(), ...this.getAutoloadedKeys(), ]); } public static getInstance(): EnvManager { if (!EnvManager.instance) { EnvManager.instance = new EnvManager(); } return EnvManager.instance; } public has(key: string): boolean { return this.allKeys.has(key); } public set(key: string, value: undefined | string): void { this.allKeys.add(key); this.envStore[key] = value; } public get(key: string): string | undefined { return this.envStore[key]; } public getAsArray(key: string): string[] { const value = this.envStore[key]; return FishVariableParser.parse(value || ''); } public getFirstValueInArray(key: string): string | undefined { return this.getAsArray(key).at(0); } public getAsTypedArray(key: string): Config.ConfigValueType | undefined { if (!this.has(key)) return undefined; const arrayValues = this.getAsArray(key); if (Array.isArray(arrayValues) && arrayValues.length === 0) return []; const isAllNumbers = arrayValues.every((val) => Number.isInteger(Number(val))); if (isAllNumbers) { return arrayValues.map((val) => Number(val) as number); } if (arrayValues.length > 0) return arrayValues; const singleValue = this.get(key); if (singleValue !== undefined) { if (Number.isInteger(Number(singleValue))) return Number(singleValue) as number; return singleValue; } return undefined; } public static isArrayValue(value: string): boolean { return FishVariableParser.parse(value).length > 1; } public isArray(key: string): boolean { return this.getAsArray(key).length > 1; } public isAutoloaded(key: string): boolean { return this.autoloadedKeys.has(key); } public isProcessEnv(key: string): boolean { return this.processEnvKeys.has(key); } public append(key: string, value: string): void { const existingValue = this.getAsArray(key); const untokenizedValue = this.get(key); if (this.isArray(key)) { const tokenSeparator = FishVariableParser.tokenSeparator(untokenizedValue || ''); existingValue.push(value); this.envStore[key] = existingValue.join(tokenSeparator); } else { this.envStore[key] = `${untokenizedValue || ''} ${value}`.trim(); } } public prepend(key: string, value: string) { const existingValue = this.getAsArray(key); const untokenizedValue = this.get(key); if (this.isArray(key)) { const tokenSeparator = FishVariableParser.tokenSeparator(untokenizedValue || ''); existingValue.unshift(value); this.envStore[key] = existingValue.join(tokenSeparator); } else { this.envStore[key] = `${value} ${untokenizedValue || ''}`.trim(); } } public get processEnv(): NodeJS.ProcessEnv { return process.env; } public get autoloadedFishVariables(): Record { const autoloadedFishVariables: Record = {}; AutoloadedPathVariables.all().forEach((variable) => { autoloadedFishVariables[variable] = this.getAsArray(variable); }); return autoloadedFishVariables; } get keys(): string[] { return Array.from(this.allKeys); } public getAutoloadedKeys(): string[] { return Array.from(this.autoloadedKeys); } public getProcessEnvKeys(): string[] { return Array.from(this.processEnvKeys); } public findAutolaodedKey(key: string): string | undefined { if (key.startsWith('$')) { key = key.slice(1); } return this.getAutoloadedKeys().find((k) => k === key || this.getAsArray(k).includes(key)); } get values() { const values: string[][] = []; for (const key in this.envStore) { values.push(this.getAsArray(key)); } return values; } get entries(): [string, string][] { return this.keys.map((key) => { const value = this.get(key); return [key, value || '']; }); } public parser() { return FishVariableParser; } public findAutoloadedFunctionPath(functionName: string): string[] { const paths: string[] = allPossibleAutoloadedFunctionPaths(functionName); const results: string[] = []; for (const p of paths) { if (fs.existsSync(p)) { results.push(p); } } return results; } /** * For testing! * Make sure to use `await setupProcessEnvExecFile()` after using this method */ public clear(): void { for (const key in this.envStore) { delete this.envStore[key]; } this.setAllKeys(); Object.assign(this.envStore, process.env); } } export const env = EnvManager.getInstance(); ================================================ FILE: src/utils/exec.ts ================================================ import { spawn, exec, execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { logger } from '../logger'; import { pathToUri, uriToPath } from './translation'; import { config } from '../config'; import GetDocs from '../../fish_files/get-docs.fish'; import GetCommandOptions from '../../fish_files/get-command-options.fish'; import GetType from '../../fish_files/get-type.fish'; import GetTypeVerbose from '../../fish_files/get-type-verbose.fish'; import GetCartisianExpansion from '../../fish_files/expand_cartesian.fish'; import GetAutoloadedFilepath from '../../fish_files/get-autoloaded-filepath.fish'; import GetFishAutoloadedPaths from '../../fish_files/get-fish-autoloaded-paths.fish'; import GetDependency from '../../fish_files/get-dependency.fish'; import GetExec from '../../fish_files/exec.fish'; import GetCompletion from '../../fish_files/get-completion.fish'; import GetDocumentation from '../../fish_files/get-documentation.fish'; export type EmbeddedFishResult = { stdout: string; stderr: string; code: number | null; }; export function runEmbeddedFish(script: string, args: string[] = []): Promise { return new Promise((resolve, reject) => { // Use fish's psub (process substitution) to source from stdin and pass arguments correctly // This approach properly handles arguments with spaces, quotes, and special characters const argsEscaped = args.map(arg => { // Escape single quotes by replacing ' with '\'' const escaped = arg.replace(/'/g, "'\\''"); return `'${escaped}'`; }).join(' '); const fishCommand = args.length > 0 ? `source (command cat | psub) ${argsEscaped}` : 'source (command cat | psub)'; const child = spawn('fish', ['-c', fishCommand], { stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => stdout += chunk); child.stderr.on('data', (chunk) => stderr += chunk); child.on('error', reject); child.on('close', (code) => { resolve({ stdout, stderr, code }); }); child.stdin.write(script); child.stdin.end(); }); } export namespace ExecFishFiles { export function getCommandOptions(...args: string[]): Promise { return runEmbeddedFish(GetCommandOptions, args); } export function getDocs(...args: string[]): Promise { return runEmbeddedFish(GetDocs, args); } export function getType(...args: string[]): Promise { return runEmbeddedFish(GetType, args); } export function getTypeVerbose(...args: string[]): Promise { return runEmbeddedFish(GetTypeVerbose, args); } export function getCartisianExpansion(...args: string[]): Promise { return runEmbeddedFish(GetCartisianExpansion, args); } export function getAutoloadedFilepath(...args: string[]): Promise { return runEmbeddedFish(GetAutoloadedFilepath, args); } export function getFishAutoloadedPaths(...args: string[]): Promise { return runEmbeddedFish(GetFishAutoloadedPaths, args); } export function getDependency(...args: string[]): Promise { return runEmbeddedFish(GetDependency, args); } export function getDocumentation(...args: string[]): Promise { return runEmbeddedFish(GetDocumentation, args); } export function execFish(cmd: string): Promise { return runEmbeddedFish(GetExec, [cmd]); } export function getCompletion(...args: string[]): Promise { return runEmbeddedFish(GetCompletion, args); } } export const execAsync = promisify(exec); export const execFileAsync = promisify(execFile); /** * @async execEscapedComplete() - executes the fish command with * * @param {string} cmd - the current command to complete * * @returns {Promise} - the array of completions, types will need to be added when * the fish completion command is implemented */ export async function execEscapedCommand(cmd: string): Promise { const escapedCommand = cmd.replace(/(["'$`\\])/g, '\\$1'); const { stdout } = await execFileAsync(config.fish_lsp_fish_path, ['-P', '--command', escapedCommand]); if (!stdout) return ['']; return stdout.trim().split('\n'); } export async function execCmd(cmd: string, options?: { interactiveMode?: boolean; shellCommand?: string; }): Promise { const shellCmd = options?.shellCommand || config.fish_lsp_fish_path || 'fish'; const prefixOpts = [ '--private', options?.interactiveMode ? '--interactive' : '', '--command', ].filter(Boolean); const { stdout, stderr } = await execFileAsync(shellCmd, [...prefixOpts, cmd]); if (stderr) return ['']; return stdout .toString() .trim() .split('\n'); } export async function execAsyncF(cmd: string) { const result = await ExecFishFiles.execFish(cmd); logger.log({ func: 'execAsyncF', result, cmd }); return result.stdout.toString().trim(); } /** * Wrapper for `execAsync()` a.k.a, `promisify(exec)` * Executes the `cmd` in a fish subprocess * * @param cmd - the string to wrap in `fish -c '${cmd}'` * * @returns Promise<{stdout, stderr}> */ export async function execAsyncFish(cmd: string) { return await execAsync(`${config.fish_lsp_fish_path} -c '${cmd}'`); } export function execFishNoExecute(filepath: string) { try { // execFileSync will throw on non-zero exit codes return execFileSync(config.fish_lsp_fish_path, ['--no-execute', filepath], { encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], // Only capture stderr }).toString(); } catch (err: any) { // When fish finds syntax errors, it exits non-zero but still gives useful output in stderr if (err.stderr) { return err.stderr.toString(); } } } export async function execCompletions(...cmd: string[]): Promise { // const file = getFishFilePath('get-completion.fish'); const cmpArgs = ['1', `${cmd.join(' ').trim()}`]; const cmps = await ExecFishFiles.getCompletion(...cmpArgs); return cmps.stdout.trim().split('\n'); } export async function execSubCommandCompletions(...cmd: string[]): Promise { const cmpArgs = ['2', cmd.join(' ')]; const cmps = await ExecFishFiles.getCompletion(...cmpArgs); return cmps.stdout.trim().split('\n'); } export async function execCompleteLine(cmd: string): Promise { const escapedCmd = cmd.replace(/(["'`\\])/g, '\\$1'); const completeString = `${config.fish_lsp_fish_path} -c "complete --do-complete='${escapedCmd}'"`; const child = await execAsync(completeString); if (child.stderr) { return ['']; } return child.stdout.trim().split('\n'); } export async function execCompleteSpace(cmd: string): Promise { const escapedCommand = cmd.replace(/(["'$`\\])/g, '\\$1'); const completeString = `${config.fish_lsp_fish_path} -c "complete --do-complete='${escapedCommand} '"`; const child = await execAsync(completeString); if (child.stderr) { return ['']; } return child.stdout.trim().split('\n'); } export async function execCompleteCmdArgs(cmd: string): Promise { const args = await ExecFishFiles.getCommandOptions(cmd); const results = args?.stdout.toString().trim().split('\n') || []; let i = 0; const fixedResults: string[] = []; while (i < results.length) { const line = results[i] as string; if (cmd === 'test') { fixedResults.push(line); } else if (!line.startsWith('-', 0)) { //fixedResults.slice(i-1, i).join(' ') fixedResults.push(fixedResults.pop() + ' ' + line.trim()); } else { fixedResults.push(line); } i++; } return fixedResults; } export async function execCommandDocs(cmd: string): Promise { const result = await ExecFishFiles.getDocs(cmd); const out = result.stdout || ''; return out.toString().trim(); } /** * runs: ../fish_files/get-type.fish * * @param {string} cmd - command type from document to resolve * @returns {Promise} * 'command' -> cmd has man * 'file' -> cmd is fish function * '' -> cmd is neither */ export async function execCommandType(cmd: string): Promise { const result = await ExecFishFiles.getType(cmd); if (result?.stderr) { return ''; } return result?.stdout?.toString().trim() || ''; } export interface CompletionArguments { command: string; args: Map; } export async function documentCommandDescription(cmd: string): Promise { const cmdDescription = await execAsync(`${config.fish_lsp_fish_path} -c "__fish_describe_command ${cmd}" | head -n1`); return cmdDescription.stdout.trim() || cmd; } export async function execFindDependency(cmd: string): Promise { const file = await ExecFishFiles.getDependency(cmd); return file?.stdout?.toString().trim() || ''; } export async function execExpandBraceExpansion(input: string): Promise { const result = await ExecFishFiles.getCartisianExpansion(input); return result?.stdout?.toString().trimEnd() || ''; } export function execCommandLocations(cmd: string): { uri: string; path: string; }[] { const output = execFileSync(config.fish_lsp_fish_path, ['--command', `type -ap ${cmd}`], { stdio: ['pipe', 'pipe', 'ignore'], }); return output.toString().trim().split('\n') .map(line => line.trim()) .filter(line => line.length > 0 && line !== '\n' && line.includes('/')) .map(line => ({ uri: pathToUri(line), path: uriToPath(line), })) || []; } ================================================ FILE: src/utils/file-operations.ts ================================================ import { PathLike, accessSync, appendFileSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs'; import { TextDocumentItem } from 'vscode-languageserver'; import { LspDocument } from '../document'; import { pathToUri } from './translation'; import { basename, dirname, extname, normalize } from 'path'; import { env } from './env-manager'; import * as promises from 'fs/promises'; import { logger } from '../logger'; /** * Synchronous file operations. */ export class SyncFileHelper { static open(filePath: PathLike, flags: string): number { const expandedFilePath = this.expandEnvVars(filePath); return openSync(expandedFilePath, flags); } static close(fd: number): void { closeSync(fd); } static read(filePath: PathLike, encoding: BufferEncoding = 'utf8'): string { try { const expandedFilePath = this.expandEnvVars(filePath); if (this.isDirectory(expandedFilePath)) { return ''; } return readFileSync(expandedFilePath, { encoding }); } catch (error) { logger.error(`Error reading file: ${filePath}`, error); return ''; } } static loadDocumentSync(filePath: PathLike): LspDocument | undefined { try { const expandedFilePath = this.expandEnvVars(filePath); // Check if path exists and is a file if (!this.exists(expandedFilePath)) { return undefined; } const stats = statSync(expandedFilePath); if (stats.isDirectory()) { return undefined; } // Read file content safely const content = readFileSync(expandedFilePath, { encoding: 'utf8' }); const uri = pathToUri(expandedFilePath.toString()); // Create document const doc = TextDocumentItem.create(uri, 'fish', 0, content); return new LspDocument(doc); } catch (error) { // Handle all possible errors without crashing // Just return undefined on any file system error return undefined; } } // Write a file synchronously static write(filePath: PathLike, data: string, encoding: BufferEncoding = 'utf8'): void { const expandedFilePath = this.expandEnvVars(filePath); writeFileSync(expandedFilePath, data, { encoding }); } // write to a file that needs a directory created first static writeRecursive(filePath: PathLike, data: string, encoding: BufferEncoding = 'utf8'): void { const expandedFilePath = this.expandEnvVars(filePath); const directory = dirname(expandedFilePath); try { mkdirSync(directory, { recursive: true }); writeFileSync(expandedFilePath, data, { encoding }); } catch (error) { logger.error(`Error writing file recursively: ${expandedFilePath}`, error); } } static append(filePath: PathLike, data: string, encoding: BufferEncoding = 'utf8'): void { const expandedFilePath = this.expandEnvVars(filePath); appendFileSync(expandedFilePath, data, { encoding }); } static expandEnvVars(filePath: PathLike): string { let filePathString = filePath.toString(); // Expand ~ to home directory filePathString = filePathString.replace(/^~/, process.env.HOME!); // Expand environment variables filePathString = filePathString.replace(/\$([a-zA-Z0-9_]+)/g, (_, envVarName) => { return env.get(envVarName) || ''; }); return filePathString; } /** * Expands environment variables and normalizes the path * - First expands ~ and $VARS using expandEnvVars() * - Then normalizes the path using path.normalize() * - Preserves relative vs absolute path semantics * @param filePath The path to expand and normalize * @returns The expanded and normalized path */ static expandNormalize(filePath: PathLike): string { const expandedPath = this.expandEnvVars(filePath); return normalize(expandedPath); } static isExpandable(filePath: PathLike): boolean { const expandedFilePath = this.expandEnvVars(filePath); return expandedFilePath !== filePath.toString() && expandedFilePath !== ''; } static exists(filePath: PathLike): boolean { const expandedFilePath = this.expandEnvVars(filePath); return existsSync(expandedFilePath); } static delete(filePath: PathLike): void { unlinkSync(filePath); } static create(filePath: PathLike) { const expandedFilePath = this.expandEnvVars(filePath); if (this.isDirectory(expandedFilePath)) { return this.getPathTokens(filePath); } else if (!this.exists(expandedFilePath)) { this.write(expandedFilePath, ''); } return this.getPathTokens(expandedFilePath); } static getPathTokens(filePath: PathLike) { const expandedFilePath = this.expandEnvVars(filePath); return { path: expandedFilePath, filename: basename(expandedFilePath, extname(expandedFilePath)), extension: extname(expandedFilePath).substring(1), directory: dirname(expandedFilePath), exists: this.exists(expandedFilePath), uri: pathToUri(expandedFilePath), }; } static convertTextToFishFunction(filePath: PathLike, data: string, _encoding: BufferEncoding = 'utf8') { const expandedFilePath = this.expandEnvVars(filePath); const { filename, path, extension, exists } = this.getPathTokens(expandedFilePath); const content = [ '', `function ${filename}`, data.split('\n').map(line => '\t' + line).join('\n'), 'end', ].join('\n'); if (exists) { this.append(path, content, 'utf8'); return this.toLspDocument(path, extension); } this.write(path, content); return this.toLspDocument(path, extension); } static toTextDocumentItem(filePath: PathLike, languageId: string, version: number): TextDocumentItem { const expandedFilePath = this.expandEnvVars(filePath); const content = this.read(expandedFilePath); const uri = pathToUri(expandedFilePath.toString()); return TextDocumentItem.create(uri, languageId, version, content); } static toLspDocument(filePath: PathLike, languageId: string = 'fish', version: number = 1): LspDocument { const expandedFilePath = this.expandEnvVars(filePath); let content = this.read(expandedFilePath); if (!content) { content = ''; } const doc = this.toTextDocumentItem(expandedFilePath, languageId, version); return new LspDocument(doc); } static isDirectory(filePath: PathLike): boolean { const expandedFilePath = this.expandEnvVars(filePath); try { const fileStat = statSync(expandedFilePath); return fileStat.isDirectory(); } catch (_) { return false; } } static isFile(filePath: PathLike): boolean { const expandedFilePath = this.expandEnvVars(filePath); try { const fileStat = statSync(expandedFilePath); return fileStat.isFile(); } catch (_) { return false; } } /** * Synchronously checks if a workspace path is a writable directory * @param workspacePath - The path to check * @returns true if path exists, is a directory, and is writable */ static isWriteableDirectory(workspacePath: string): boolean { const expandedPath = this.expandEnvVars(workspacePath); if (!this.isDirectory(expandedPath)) { return false; } return this.isWriteablePath(expandedPath); } static isWriteableFile(filePath: string): boolean { const expandedFilePath = this.expandEnvVars(filePath); if (!this.isFile(expandedFilePath)) { return false; } return this.isWriteablePath(expandedFilePath); } static isWriteable(filePath: string): boolean { const expandedFilePath = this.expandEnvVars(filePath); return this.isWriteablePath(expandedFilePath); } private static isWriteablePath(path: string): boolean { try { accessSync(path, constants.W_OK); return true; } catch (error) { return false; } } static isAbsolutePath(filePath: string): boolean { const expandedFilePath = this.expandEnvVars(filePath); return expandedFilePath.startsWith('/') || expandedFilePath.startsWith('~'); } static isRelativePath(filePath: string): boolean { const expandedFilePath = this.expandEnvVars(filePath); return !this.isAbsolutePath(expandedFilePath); } } export namespace AsyncFileHelper { export async function isReadable(filePath: string): Promise { const expandedFilePath = SyncFileHelper.expandEnvVars(filePath); try { await promises.access(expandedFilePath, promises.constants.R_OK); return true; } catch { return false; } } export async function isDir(filePath: string): Promise { const expandedFilePath = SyncFileHelper.expandEnvVars(filePath); try { const fileStat = await promises.stat(expandedFilePath); return fileStat.isDirectory(); } catch { return false; } } export async function isFile(filePath: string): Promise { const expandedFilePath = SyncFileHelper.expandEnvVars(filePath); try { const fileStat = await promises.stat(expandedFilePath); return fileStat.isFile(); } catch { return false; } } export async function readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise { const expandedFilePath = SyncFileHelper.expandEnvVars(filePath); return promises.readFile(expandedFilePath, { encoding }); } } ================================================ FILE: src/utils/flag-documentation.ts ================================================ import { MarkupContent, MarkupKind } from 'vscode-languageserver-protocol/node'; import { execCommandDocs, execCompleteLine } from './exec'; const findFirstFlagIndex = (cmdline: string[]) => { for (let i = 0; i < cmdline.length; i++) { const arg = cmdline[i] as string; if (arg.startsWith('-')) { return i; } } return -1; }; const findFlagStopToken = (inputArray: string[]) => { for (let i = 0; i < inputArray.length; i++) { const arg = inputArray[i]; if (arg === '--') { return i; } } return -1; }; const ensureEndOfArgs = (inputArray: string[]) => { const stopToken = findFlagStopToken(inputArray); return stopToken === -1 ? inputArray : inputArray.slice(0, stopToken); }; const removeStrings = (input: string) => { let output = input.replace(/^\s+/, ''); output = output.replace(/^if\s+/, ''); output = output.replace(/^else {2}if\s+/, ''); output = output.replace(/"(.+)"/, ''); output = output.replace(/'(.+)'/, ''); return output; }; const tokenizeInput = (input: string) => { const removed = removeStrings(input); const tokenized = ensureEndOfArgs(removed.split(/\s/)); return tokenized.filter(t => t.length > 0); }; const generateShellCommandToComplete = (cmdline: string[]) => { const firstFlag = findFirstFlagIndex(cmdline); const cmd = cmdline.slice(0, firstFlag); cmd.push('-'); return cmd.join(' '); }; const outputFlags = async (inputArray: string[]) => { const toExec = generateShellCommandToComplete(inputArray); const output = await execCompleteLine(toExec); return output.filter((line) => line.startsWith('-')); }; const shortFlag = (flag: string) => { return flag.startsWith('-') && !flag.startsWith('--'); }; const longFlag = (flag: string) => { return flag.startsWith('--') && flag.length > 2; }; const hasUnixFlags = (allFlagLines: string[]) => { for (const line of allFlagLines) { const [flag, _doc]: string[] = line.split('\t') || []; if (!flag) { continue; } if (shortFlag(flag) && flag.length > 2) { return true; } } return false; }; const parseInputFlags = (inputArray: string[], separateShort: boolean) => { const result: string[] = []; for (let i = 0; i < inputArray.length; i++) { const arg = inputArray[i]; if (arg && shortFlag(arg)) { if (separateShort) { const shortFlags = arg.slice(1).split('').map(ch => '-' + ch); result.push(...shortFlags); } else { result.push(arg); } } else if (arg && longFlag(arg)) { result.push(arg); } } return result; }; const findMatchingFlags = (inputFlags: string[], allFlagLines: string[]) => { const output: string[] = []; for (const line of allFlagLines) { const [flag, _doc] = line.split('\t'); if (flag && inputFlags.includes(flag)) { output.push(line); } } return output; }; async function getFlagDocumentationStrings(input: string) : Promise { const splitInputArray = tokenizeInput(input); const outputFlagLines = await outputFlags(splitInputArray); const shouldSeparateShortFlags = !hasUnixFlags(outputFlagLines); const parsedInputFlags = parseInputFlags(splitInputArray, shouldSeparateShortFlags); const matchingFlags = findMatchingFlags(parsedInputFlags, outputFlagLines); return matchingFlags .map(line => line.split('\t')) .map(([flag, doc]) => `**\`${flag}\`** *\`${doc}\`*`) .reverse(); } export function getFlagCommand(input: string) : string { const splitInputArray = tokenizeInput(input); const firstFlag = findFirstFlagIndex(splitInputArray); let cmd = splitInputArray; if (firstFlag !== -1) { cmd = splitInputArray.slice(0, firstFlag); } return cmd.join(' '); } export async function getFlagDocumentationAsMarkup(input: string) : Promise { const docString = await getFlagDocumentationString(input); return { kind: MarkupKind.Markdown, value: docString, }; } export async function getFlagDocumentationString(input: string): Promise { const cmdName = getFlagCommand(input); const flagLines = await getFlagDocumentationStrings(input); const flagString = flagLines.join('\n'); const manpage = await execCommandDocs(cmdName.replaceAll(' ', '-')); const flagDoc = flagString.trim().length > 0 ? ['___', ' ***Flags***', flagString].join('\n') : ''; const manDoc = manpage.trim().length > 0 ? ['___', '```man', manpage, '```'].join('\n') : ''; const afterString = [flagDoc, manDoc].join('\n').trim(); return [ `***\`${cmdName}\`***`, afterString, ].join('\n'); } ================================================ FILE: src/utils/flatten.ts ================================================ /** * ___Example types for flattening include:___ \`SyntaxNode\`, \`FishDocumentSymbol\`, and \`DocumentSymbol\` * * --- * * ```typescript * flattenNested(...[ * {name: 'foo', kind: 'function', children: [ * {name: 'a', kind: 'variable', children: []}, * {name: 'b', kind: 'variable', children: []}, * {name: 'c', kind: 'variable', children: []}, * ]}, * {name: 'bar', kind: 'function', children: []}, * {name: 'baz', kind: 'function', children: []}, * ]); // [foo, a, b, c, bar, baz] * ``` * * --- * * __Flattens__ a __nested array__ of objects with a __\`children\` property__. * * @param roots an _array_ of objects with a `children` property. * * @returns a _flat_ array of all objects and their children. */ export function flattenNested(...roots: T[]): T[] { const result: T[] = []; let index = 0; result.push(...roots); while (index < result.length) { const current = result[index++]; if (current?.children) result.push(...current.children); } return result; } /** * Generator function that iterates over a nested structure of objects with a \`children\` property * in the same DFS order used by the `flattenNested` function. */ export function* iterateNested(...roots: T[]): Generator { // Create a queue starting with the root nodes const queue: T[] = [...roots]; // Process nodes in the queue one by one while (queue.length > 0) { // Get the next node from the front of the queue const current = queue.shift()!; // Yield the current node yield current; // Add its children to the end of the queue (if any) if (current?.children) { queue.push(...current.children); } } } ================================================ FILE: src/utils/get-lsp-completions.ts ================================================ import { Command } from 'commander'; import os from 'os'; import { Config, validHandlers } from '../config'; import { PkgJson } from './commander-cli-subcommands'; function getAutoGeneratedHeader(): string { return `# AUTO GENERATED BY 'fish-lsp' COMMAND # # * Any command should generate the completions file # # >_ fish-lsp complete > ~/.config/fish/completions/fish-lsp.fish # >_ fish-lsp complete > $fish_complete_path[1]/fish-lsp.fish # # * If you are building from source, the completions file is generated by the commands # # >_ yarn install && yarn build # builds and links the \`fish-lsp\` command globally (with completions) # >_ yarn sh:build-completions # directly builds the completions file # # * To find all files that are used for sourcing fish-lsp's completions, you can use: # # >_ path sort --unique --key=basename $fish_complete_path/*.fish | string match -re '/fish-lsp.fish' # # * To interactively test the completions, you can use: # # >_ complete -c fish-lsp -e && complete -e fish-lsp # erase all fish-lsp completions # >_ fish-lsp complete | source # use the completions for the current session # # * For more info, try editing the generated output inside: # # To see the completions in your current shell interactive prompt: # >_ commandline -r (fish-lsp complete | string collect) # pressing \`alt+e\` will edit the commandline in $EDITOR # # Or write them to a /tmp/ file, and edit them directly in your $EDITOR, and source them: # >_ fish-lsp complete > /tmp/fish-lsp.fish && $EDITOR /tmp/fish-lsp.fish && source /tmp/fish-lsp.fish # # If you are working on the development of the \`fish-lsp\`, you can edit the file that generates the completions: # >_ $EDITOR ~/path/to/fish-lsp/src/utils/get-lsp-completions.ts # >_ open https://github.com/ndonfris/fish-lsp/blob/master/src/utils/get-lsp-completions.ts # view it in browser # # NOTE: bundled server installations will not allow you to view the source code as easily # # * You can see if the completions are up to date by running the command: # # >_ fish-lsp info --check-health # # # MORE INFO INCLUDED IN FOOTER # PLEASE CONSIDER CONTRIBUTING! # REPO URL: ${PkgJson.repository} `; } function getHelperFunctions(): string { const allValidHandlers = validHandlers || Config.allServerFeatures; return ` ############################################# # helper functions for fish-lsp completions # ############################################# # print all unique \`fish-lsp start --enable|--disable ...\` features (i.e., complete, hover, etc.) # if a feature is already specified in the command line, it will be skipped # the features can also be used in the global environment variables \`fish_lsp_enabled_handlers\` or \`fish_lsp_disabled_handlers\` function __fish_lsp_get_features -d 'print all features controlled by the server, not yet used in the commandline' set -l all_features ${allValidHandlers?.map(handlerName => `'${handlerName}'`).join(' ')} set -l features_to_complete set -l features_to_skip set -l opts (commandline -opc) for opt in $opts if contains -- $opt $all_features set features_to_skip $features_to_skip $opt end end for feature in $all_features if not contains -- $feature $features_to_skip printf '%b\\t%s\\n' $feature "$feature handler" end end end # check if \`fish_lsp info\` is used without arguments that prevent more switches # to be completed. \`$argv\` can be multiple switches that are truthy for \`not __fish_contains_opt $arg\`. # EXAMPLES: # > \`__fish_info_complete_opt\` # no arguments so it will only check base cases # > \`fish-lsp info -\` ---> $status -eq 0 # > \`fish-lsp info --extra\` ---> $status -eq 1 # # > \`__fish_info_complete_opt bin\` # check if argument \`--bin\` is used # > \`fish-lsp info --bin\` ---> $status -eq 1 # > \`fish-lsp info --time-startup\` ---> $status -eq 1 (base case) function __fish_lsp_info_complete_opt --description 'check if the commandline contains any of the info switches' __fish_seen_subcommand_from info || return 1 begin __fish_contains_opt extra or __fish_contains_opt verbose or __fish_contains_opt time-startup or __fish_contains_opt check-health or __fish_contains_opt source-maps or __fish_contains_opt dump-parse-tree or __fish_contains_opt dump-symbol-tree or __fish_contains_opt dump-semantic-tokens end && return 1 for opt in $argv not __fish_contains_opt "$opt" or return 1 end return 0 end # print all unique \'fish-lsp env --only ...\` env_variables (i.e., $fish_lsp_*, ...) # if a env_variable is already specified in the command line, it will not be included again function __fish_lsp_get_env_variables -d 'print all fish_lsp_* env variables, not yet used in the commandline' # every env variable name set -l env_names ${Config.allKeys?.map(k => `"${k}"`).join(' \\\n\t\t')} # every completion argument \`name\\t'description'\`, only unused env variables will be printed set -l env_names_with_descriptions ${Object.entries(Config.envDocs).map(([k, v]) => `"${k}\\t'${v}'"`).join(' \\\n\t\t')} # get the current command line token (for comma separated options) set -l current (commandline -ct) # utility function to check if the current token contains a comma function has_comma --inherit-variable current --description 'check if the current token contains a comma' string match -rq '.*,.*' -- $current || string match -rq -- '--only=.*' $current return $status end # get the current command line options, adding the current token if it contains a comma set -l opts (commandline -opc) has_comma && set -a opts $current # create two arrays, one for the env variables already used, and the other # for all the arguments passed into the commandline set -l features_to_skip set -l fixed_opts # split any comma separated options for opt in $opts if string match -rq -- '--only=.*' $opt set -a fixed_opts '--only' (string split -m1 -f2 -- '--only=' $opt | string split ',') else if string match -q '*,*' -- $opt set fixed_opts $fixed_opts (string split ',' -- $opt) else set fixed_opts $fixed_opts $opt end end # skip any env variable that is already specified in the command line for opt in $fixed_opts if contains -- $opt $env_names set -a features_to_skip $opt end end # if using the \`--only=\` syntax, remove the \`--only\` part. # when entries are separated by commas, we need to keep the current token's prefix comma # in the completion output set prefix '' if has_comma set prefix (string replace -r '[^,]*$' '' -- $current | string replace -r -- '^--only=' '') end # print the completions that haven't been used yet for line in $env_names_with_descriptions set name (string split -f1 -m1 '\\t' -- $line) if not contains -- $name $features_to_skip echo -e "$prefix$line" end end end # check for usage of the main switches in env command \`fish-lsp env --show|--create|--show-default|--names\` # # requires passing in one of switches: \`--none\` or \`--any\` # - \`--none\` check that none of the main switches are used # - \`--any\` check that a main switch has been seen # - \`--no-names\` check that the \`--names\` switch is not used, but needs to be # paired with \`--none\` or \`--any\` # # used in the \`env\` completions, for grouping repeated logic on those # completions conditional checks. # # \`\`\` # complete -n '__fish_lsp_env_main_switch --none' # \`\`\` function __fish_lsp_env_main_switch --description 'check if the commandline contains any of the main env switches (--show|--create|--show-default|--names)' argparse any none no-names no-output-types no-json names-joined -- $argv or return 1 # none means we don't want to see any of the main switches # no-names doesn't change anything here, since we are making sure that # names already doesn't exist in the command line if set -ql _flag_none not __fish_contains_opt names and not __fish_contains_opt -s s show and not __fish_contains_opt -s c create and not __fish_contains_opt show-default return $status end # any means that one of the main switches has been used. if set -ql _flag_any if set -ql _flag_no_names __fish_contains_opt names and return 1 end if set -ql _flag_no_output_types __fish_contains_opt json or __fish_contains_opt confd and return 1 end if set -ql _flag_no_json __fish_contains_opt json and return 1 end not set -ql _flag_no_names && __fish_contains_opt names or __fish_contains_opt -s s show or __fish_contains_opt -s c create or __fish_contains_opt show-default return $status end # names joined means that both the --names and --joined switches are used if set -ql _flag_names_joined __fish_contains_opt names and not __fish_contains_opt -s j joined and return $status end # if no switches are found, return 1 return 1 end # make sure \`fish-lsp start --stdio|--node-ipc|--socket\` is used singularly # and not in combination with any other connection related option function __fish_lsp_start_connection_opts -d 'check if any option (--stdio|--node-ipc|--socket) is used' __fish_contains_opt stdio || __fish_contains_opt node-ipc || __fish_contains_opt socket end # check if the last \`fish-lsp start ...\` flag/switch is \`--enable\` or \`--disable\` # this will find the last \`-*\` argument in the command line, skipping any argument not starting with \`-\` # and make sure it matches any of the provided \`$argv\` passed in to the function (defaulting to: \`--enable\` \`--disable\`) # we use this to allow multiple sequential features to follow \`fish-lsp start --enable|--disable ...\` # USAGE: # > \`fish-lsp --stdio --start complete hover --disable codeAction highlight formatting \` # \`__fish_lsp_last_switch --enable --disable \` would return 0 since \`--disable\` is the last switch function __fish_lsp_last_switch -d 'check if the last argument w/ a leading \`-\` matches any $argv' set -l opts (commandline -opc) set -l last_opt for opt in $opts switch $opt case '-*' set last_opt $opt case '*' continue end end set -l match_opts $argv if test (count $argv) -eq 0 set match_opts '--enable' '--disable' end for switch in $match_opts if test "$last_opt" = "$switch" return 0 end end return 1 end # Utility function to check if non or switches have been seen in the commandline # EXAMPLES: # > \`__fish_lsp_not_contains_opt stdio enable disable\` # > \`fish-lsp start --stdio \` ---> 1 # > \`fish-lsp start --enable \` ---> 1 # > \`fish-lsp start --stdio --enable complete \` ---> 1 function __fish_lsp_not_contains_opt -d 'check if no switches have been seen in the commandline' for opt in $argv not __fish_contains_opt "$opt" or return 1 end end function __fish_lsp_info_sourcemaps_complete -d 'complete the source map url for the current lsp version' __fish_seen_subcommand_from info and __fish_contains_opt source-maps and __fish_lsp_not_contains_opt all all-paths check status end # Utility function for checking if we have seen any switches yet. # EXAMPLES: # > \`fish-lsp start --stdio \` ---> 1 # > \`fish-lsp start --stdio --enable complete \` ---> 1 # > \`fish-lsp start \` ---> 0 function __fish_lsp_is_first_switch -d "check if we've seen any switches in the commandline" set -l opts (commandline -opc) set -e opts[1] if test (count $opts) -eq 0 return 1 end set -l count 0 for opt in $opts switch $opt case '-*' set count (math $count + 1) case '*' continue end end if test $count -eq 0 return 0 end return 1 end # Count args after the last switch. # This is useful for limiting the number of arguments a user can pass to a switch. # Fish's default behavior allows multiple arguments to be passed to a switch (when using the fish-lsp's cli syntax) # When paired with the \`test\` command, we can make sure that the user has provided a certain number of values to the last switch. # EXAMPLES: # > \`fish-lsp start --max-files \` ---> 0 # > \`fish-lsp start --max-files 10\` ---> 1 # > \`fish-lsp start --max-files 1000 \` ---> 2 # > \`fish-lsp start --max-files 1000 --stdio\` ---> 0 # USAGE: # > \`complete -c fish-lsp -n 'not __fish_contains_opt max-files' -l max-files -xa '(seq 1000 500 10000)'\` # > \`complete -c fish-lsp -n 'test (__fish_lsp_count_after_last_switch) -le 1' -l max-files -xa '(seq 1000 500 10000)'\` # creates the flag \`--max-files\` with numbers from 1000 to 10000 as completion values # but only allows the user to select a single value for the switch, # e.g. \`--max-files \` is allowed, but \`--max-files 1000 5000\` is not function __fish_lsp_count_after_last_switch -d 'count the number of arguments after the last switch' set -l opts (commandline -opc) (commandline -ct) set -l last_switch set -l count 0 for opt in $opts switch $opt case '-*' set last_switch $opt set count 0 case '*' test -n "$last_switch" and set count (math $count + 1) end end echo $count end ############################### ### END OF HELPER FUNCTIONS ### ############################### `; } const noFishLspSubcommands: string = `## \`fish-lsp -\` complete -c fish-lsp -n '__fish_is_first_arg; and not __fish_contains_opt -s v version' -s v -l version -d 'Show lsp version' complete -c fish-lsp -n '__fish_is_first_arg; and not __fish_contains_opt -s h help' -s h -l help -d 'Show help information' complete -c fish-lsp -n '__fish_is_first_arg; and not __fish_contains_opt help-all' -l help-all -d 'Show all help information' complete -c fish-lsp -n '__fish_is_first_arg; and not __fish_contains_opt help-short' -l help-short -d 'Show short help information' complete -c fish-lsp -n '__fish_is_first_arg; and not __fish_contains_opt help-man' -l help-man -d 'Show raw manpage' `; const startCompletions: string = `## \`fish-lsp start --\` complete -c fish-lsp -n '__fish_seen_subcommand_from start; and not __fish_contains_opt dump' -l dump -d 'stop lsp & show the startup options being read' complete -c fish-lsp -n '__fish_seen_subcommand_from start' -l enable -d 'enable the startup option' -xa '(__fish_lsp_get_features)' complete -c fish-lsp -n '__fish_seen_subcommand_from start' -l disable -d 'disable the startup option' -xa '(__fish_lsp_get_features)' complete -c fish-lsp -n '__fish_seen_subcommand_from start; and __fish_lsp_last_switch --disable --enable' -a '(__fish_lsp_get_features)' # allow completing multiple features in a row (when last seen switch is either: \`--enable|--disable\`) complete -c fish-lsp -n '__fish_seen_subcommand_from start; and not __fish_lsp_start_connection_opts' -l stdio -d 'use stdin/stdout for communication (default)' complete -c fish-lsp -n '__fish_seen_subcommand_from start; and not __fish_lsp_start_connection_opts' -l node-ipc -d 'use node IPC for communication' complete -c fish-lsp -n '__fish_seen_subcommand_from start; and not __fish_lsp_start_connection_opts' -l socket -d 'use TCP socket for communication' -x complete -c fish-lsp -n '__fish_seen_subcommand_from start; and not __fish_contains_opt memory-limit' -l memory-limit -d 'set memory usage limit in MB' -x complete -c fish-lsp -n '__fish_seen_subcommand_from start; and not __fish_contains_opt max-files' -l max-files -d 'override the maximum number of files to analyze' -xa '100 500 (seq 1000 500 10000)' complete -c fish-lsp -n '__fish_seen_subcommand_from start; and __fish_lsp_last_switch --max-files; and test (__fish_lsp_count_after_last_switch) -le 1' -d 'override the maximum number of files to analyze' -xa '100 500 (seq 1000 500 10000)' `; /** * Syntax for urlCompletions does not match other completions because it is not influenced * by receiving multiple duplicated arguments */ const urlCompletions: string = `## fish-lsp url -- complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt repo' -l repo -d 'show git repo url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt git' -l git -d 'show git repo url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt npm' -l npm -d 'show npmjs.com url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt homepage' -l homepage -d 'show website url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt contributing' -l contributing -d 'show git CONTRIBUTING.md url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt wiki' -l wiki -d 'show git wiki url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt issues' -l issues -d 'show git issues url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt report' -l report -d 'show git issues url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt discussions' -l discussions -d 'show git discussions url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt clients-repo' -l clients-repo -d 'show git clients-repo url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download; and not __fish_contains_opt sources-list' -l sources-list -d 'show useful url list of sources' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt download' -l download -d 'download server url' complete -c fish-lsp -n '__fish_seen_subcommand_from url; and not __fish_contains_opt source-map' -l source-map -d 'show source map url for the current lsp version' `; const completeCompletions: string = `## fish-lsp complete -- complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt fish' -l fish -d 'DEFAULT BEHAVIOR: show output for completion/fish-lsp.fish' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt names' -l names -d 'show names of subcommands' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt names-with-summary' -l names-with-summary -d 'show \`name\\tsummary\\n\` of subcommands' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt features' -l features -d 'show feature/toggle names' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt toggles' -l toggles -d 'show feature/toggle names' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt env-variables' -l env-variables -d 'show env variable completions' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt env-variable-names' -l env-variable-names -d 'show env variable names' complete -c fish-lsp -n '__fish_seen_subcommand_from complete; and not __fish_contains_opt abbreviations' -l abbreviations -d 'output \`fish-lsp\` abbreviations' `; const infoCompletions: string = `## fish-lsp info -- complete -c fish-lsp -n '__fish_lsp_info_complete_opt bin' -l bin -d 'show the binary path' complete -c fish-lsp -n '__fish_lsp_info_complete_opt path' -l path -d 'show the path to the installation' complete -c fish-lsp -n '__fish_lsp_info_complete_opt build-type' -l build-type -d 'show the build-type' complete -c fish-lsp -n '__fish_lsp_info_complete_opt build-time' -l build-time -d 'show the build-time' complete -c fish-lsp -n '__fish_lsp_info_complete_opt -s v version' -s v -l version -d 'show the "fish-lsp" version' complete -c fish-lsp -n '__fish_lsp_info_complete_opt lsp-version' -l lsp-version -d 'show the npm package for the lsp-version' complete -c fish-lsp -n '__fish_lsp_info_complete_opt capabilities' -l capabilities -d 'show the lsp capabilities implemented' complete -c fish-lsp -n '__fish_lsp_info_complete_opt man-file' -l man-file -d 'show man file path' complete -c fish-lsp -n '__fish_lsp_info_complete_opt log-file' -l log-file -d 'show log file path' complete -c fish-lsp -n '__fish_lsp_info_complete_opt; and __fish_contains_opt man-file; or __fish_contains_opt log-file' -l show -d 'show file content' complete -c fish-lsp -n '__fish_lsp_info_complete_opt verbose extra; and __fish_lsp_is_first_switch' -l verbose -d 'show all debugging server info (capabilities, paths, version, etc.)' complete -c fish-lsp -n '__fish_lsp_info_complete_opt extra verbose; and __fish_lsp_is_first_switch' -l extra -d 'show all debugging server info (capabilities, paths, version, etc.)' complete -c fish-lsp -n '__fish_lsp_info_complete_opt check-health time-startup; and __fish_lsp_is_first_switch' -l check-health -d 'show the server health' complete -c fish-lsp -n '__fish_lsp_info_complete_opt time-startup check-health; and __fish_lsp_is_first_switch' -l time-startup -d 'show startup timing info' complete -c fish-lsp -n '__fish_lsp_info_complete_opt time-only;' -l time-only -d 'show only summary of the startup timing info' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt time-startup; and not __fish_contains_opt no-warning' -l no-warning -d 'do not show warning message' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt time-startup; and not __fish_contains_opt use-workspace' -l use-workspace -d 'specify workspace directory' -xa '(__fish_complete_directories)' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt time-startup; and __fish_lsp_last_switch --use-workspace' -d 'workspace directory' -xa '(__fish_complete_directories)' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt time-startup; and not __fish_contains_opt show-files' -l show-files -d 'show the files indexed' complete -c fish-lsp -n '__fish_lsp_info_complete_opt source-maps;' -l source-maps -d 'show the source maps used by the server' complete -c fish-lsp -n '__fish_lsp_info_sourcemaps_complete' -l all -d 'verbose info showing all sourcemaps used by the server on the local machine' complete -c fish-lsp -n '__fish_lsp_info_sourcemaps_complete' -l all-paths -d 'the absolute paths of the installed sourcemaps' complete -c fish-lsp -n '__fish_lsp_info_sourcemaps_complete' -l check -d 'check if the sourcemaps are installed & valid' complete -c fish-lsp -n '__fish_lsp_info_sourcemaps_complete' -l status -d 'info about the sourcemaps' complete -c fish-lsp -n '__fish_lsp_info_complete_opt dump-parse-tree; and __fish_lsp_is_first_switch' -l dump-parse-tree -d 'dump the tree-sitter parse tree of a file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show tree-sitter AST" | string match -rei -- ".*\\.fish|.*/")' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_lsp_last_switch --dump-parse-tree; and test (__fish_lsp_count_after_last_switch) -le 1' -d 'fish script file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show tree-sitter AST" | string match -rei -- ".*\\.fish|.*/")' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt dump-parse-tree; and not __fish_contains_opt no-color' -l no-color -d 'do not colorize the output' complete -c fish-lsp -n '__fish_lsp_info_complete_opt dump-semantic-tokens; and __fish_lsp_is_first_switch' -l dump-semantic-tokens -d 'dump the semantic-tokens of a file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show tree-sitter AST" | string match -rei -- ".*\\.fish|.*/")' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_lsp_last_switch --dump-semantic-tokens; and test (__fish_lsp_count_after_last_switch) -le 1' -d 'fish script file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show tree-sitter AST" | string match -rei -- ".*\\.fish|.*/")' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt dump-semantic-tokens; and not __fish_contains_opt no-color' -l no-color -d 'do not colorize the output' complete -c fish-lsp -n '__fish_lsp_info_complete_opt dump-symbol-tree; and __fish_lsp_is_first_switch' -l dump-symbol-tree -d 'dump the symbol tree of a file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show symbol tree" | string match -rei -- ".*\\.fish|.*/")' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_lsp_last_switch --dump-symbol-tree; and test (__fish_lsp_count_after_last_switch) -le 1' -d 'fish script file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show symbol tree" | string match -rei -- ".*\\.fish|.*/")' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt dump-symbol-tree; and not __fish_contains_opt no-color' -l no-color -d 'do not colorize the output' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt dump-symbol-tree; and not __fish_contains_opt no-icons' -l no-icons -d 'use plain text tags (f/v/e) instead of nerdfont icons' complete -c fish-lsp -n '__fish_seen_subcommand_from info; and __fish_contains_opt no-icons; and not __fish_contains_opt dump-symbol-tree' -l dump-symbol-tree -d 'dump the symbol tree of a file' -k -xa '(__fish_complete_suffix "*.fish" --description="path to show symbol tree" | string match -rei -- ".*\\.fish|.*/")' `; const envCompletions: string = `## fish-lsp env -- # no switches seen: \`fish-lsp env \` complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --none; and __fish_complete_subcommand --fcs-skip=2' -kra " --show-default\\t'show the default values for fish-lsp env variables' -c\\t'create the env variables' --create\\t'create the env variables' -s\\t'show the current fish-lsp env variables with their values' --show\\t'show the current fish-lsp env variables with their values' --names\\t'output only the names of the env variables'" # main switches (first arguments after the \`env\` subcommand) complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --none' -l show-default -d 'show the default values for fish-lsp env variables' -k complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --none' -s c -l create -d 'build initial fish-lsp env variables' -k complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --none' -s s -l show -d 'show the current fish-lsp env variables' -k complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --none' -l names -d 'output only the names of the env variables' -k # --only switch complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any' -l only -d 'show only certain env variables' -xa '(__fish_lsp_get_env_variables)' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_last_switch --only' -xa '(__fish_lsp_get_env_variables)' # switches usable after the main switches complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any --no-names --no-json; and not __fish_contains_opt no-comments' -l no-comments -d 'skip outputting comments' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any --no-names --no-json; and not __fish_contains_opt no-global' -l no-global -d 'use local exports' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any --no-names --no-json; and not __fish_contains_opt no-local' -l no-local -d 'do not use local scope (pair with --no-global)' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any --no-names --no-json; and not __fish_contains_opt no-export' -l no-export -d 'do not export variables' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any --no-names --no-output-types' -l json -d 'output for settings.json' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --any --no-names --no-output-types' -l confd -d 'output for redirect to "conf.d/fish-lsp.fish"' complete -c fish-lsp -n '__fish_seen_subcommand_from env; and __fish_lsp_env_main_switch --names-joined; and not __fish_contains_opt joined' -l joined -d 'output the names in a single line' `; function getAutoGeneratedFooter(): string { const footerItems = [ `### generated output time: ${new Date().toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium' })}`, `### binary build time: ${PkgJson?.buildTimeObj?.timestamp}`, `### binary build path: ${PkgJson?.bin.replace(os.homedir(), '~')}`, `### binary build version: ${PkgJson?.version}`, `### report issues: ${PkgJson?.bugs.url}`, ]; const footerBorderLength = footerItems.reduce((max, currentString) => { return Math.max(max, currentString.length + 3); }, 0); return [ '#'.repeat(footerBorderLength + 3), ...footerItems.map(item => item.padEnd(footerBorderLength, ' ') + '###'), '#'.repeat(footerBorderLength + 3), ].join('\n'); } // firefox-dev https://github.com/fish-shell/fish-shell/blob/master/share/completions/cjxl.fish export function buildFishLspCompletions(commandBin: Command) { const subcmdStrs = commandBin.commands.map(cmd => `${cmd.name()}\\t'${cmd.summary()}'`).join('\n'); const output: string[] = []; output.push(getAutoGeneratedHeader()); output.push(getHelperFunctions()); // Remove the cached completions // This is incase a maintainer has `$__fish_data_dir/completions/fish-lsp.fish` already cached if (process.env.FISH_LSP_COMPLETIONS_CACHE_DISABLE === 'true') { output.push('## remove cached completions'); output.push('complete -c fish-lsp -e'); output.push('complete -e fish-lsp'); } // default completions output.push('## disable file completions'); output.push('complete -c fish-lsp -f', ''); output.push('## fish-lsp '); output.push(`complete -c fish-lsp -n "__fish_is_first_arg; and __fish_complete_subcommand" -k -a "\n${subcmdStrs}\"`, ''); // fish-lsp output.push(noFishLspSubcommands); // flags for `fish-lsp start --` output.push(startCompletions); // fish-lsp url -- output.push(urlCompletions); // fish-lsp complete -- output.push(completeCompletions); // fish-lsp info -- output.push(infoCompletions); // fish-lsp env -- output.push(envCompletions); // footer comment section output.push(getAutoGeneratedFooter()); return output.join('\n'); } export function buildFishLspAbbreviations() { return [ 'abbr -a --command fish-lsp -- h --help', 'abbr -a --command fish-lsp -- c complete', 'abbr -a --command fish-lsp -- i info', 'abbr -a --command fish-lsp -- e env', 'abbr -a --command fish-lsp -- s start', 'abbr -a --command fish-lsp -- il info --log-file', 'abbr -a --command fish-lsp -- ilf info --log-file', 'abbr -a --command fish-lsp -- it info --time-startup', 'abbr -a --command fish-lsp -- its info --time-startup', 'abbr -a --command fish-lsp -- ic info --check-health', 'abbr -a --command fish-lsp -- ich info --check-health', 'abbr -a --command fish-lsp -- sd start --dump', 'abbr -a --command fish-lsp -- se start --enable', 'abbr -a --command fish-lsp -- d info --dump-parse-tree', 'abbr -a --command fish-lsp -- id info --dump-parse-tree', ].join('\n'); } ================================================ FILE: src/utils/health-check.ts ================================================ import * as fs from 'fs'; import * as path from 'path'; import { config } from '../config'; import { logger } from '../logger'; import { initializeParser } from '../parser'; import { execAsyncFish } from './exec'; import { SyncFileHelper } from './file-operations'; import { env } from './env-manager'; import { DepVersion, PkgJson } from './commander-cli-subcommands'; export async function performHealthCheck() { logger.logToStdout('fish-lsp health check'); logger.logToStdout('='.repeat(21)); // check info about the fish-lsp binary logger.logToStdout('\nchecking `fish-lsp` command:'); try { const fishLspVersion = PkgJson.version; logger.logToStdout(`✓ fish-lsp version: v${fishLspVersion}`); } catch (error) { logger.logToStdout('✗ fish-lsp version not found'); } // check if fish-lsp binary is in path try { const fishLspPath = (await execAsyncFish('command -v fish-lsp')).stdout.toString().trim(); if (fishLspPath) { logger.logToStdout(`✓ fish-lsp binary found: ${fishLspPath}`); } else { logger.logToStdout('✗ fish-lsp binary not found in PATH'); } } catch (error) { logger.logToStdout('✗ fish-lsp binary not found in PATH'); process.exit(1); } logger.logToStdout('\nchecking dependencies:'); // Check if fish shell is available try { const fishVersion = (await execAsyncFish('fish --version | string match -r "\\d.*\\$"')).stdout.toString().trim(); logger.logToStdout(`✓ fish shell: v${fishVersion}`); } catch (error) { logger.logToStdout('✗ fish shell not found or not working correctly'); process.exit(1); } // Check tree-sitter try { await initializeParser().then(() => { logger.logToStdout('✓ tree-sitter initialized successfully'); }); } catch (e: any) { logger.logToStdout(`✗ tree-sitter initialization failed: ${e.message}`); process.exit(1); } if (isNodeVersionGreaterThanMinimumRequiredVersion()) { logger.logToStdout(`✓ node version satisfies minimum version '>=${PkgJson.node.raw}' (current version: ${process.versions.node})`); } else { logger.logToStdout(`✗ node version doesn't satisfy minimum version '>=${PkgJson.node.raw}' (current version: ${process.versions.node})`); } // Check file permissions await logFishLspConfig(); // Check log file logger.logToStdout('\nchecking log file:'); if (config.fish_lsp_log_file) { logger.logToStdout(`✓ log file found: ${config.fish_lsp_log_file}`); try { const logDir = path.dirname(config.fish_lsp_log_file); await fs.promises.access(logDir, fs.constants.W_OK); logger.logToStdout(`✓ log directory is writable: ${logDir}`); } catch (error) { logger.logToStdout(`✗ cannot write to log directory: ${path.dirname(config.fish_lsp_log_file)}`); } } else { logger.logToStdout('✗ log file not specified'); } try { logger.logToStdout('\nchecking completions:'); const completions = (await execAsyncFish('path sort --unique --key=basename $fish_complete_path/*.fish | string match -re "\./fish-lsp.fish\\$"')).stdout.toString().trim(); if (completions) { logger.logToStdout(`✓ completions file found: ${completions}`); } else { CheckHealthErrorMessages.completionsFile.globalNotFound(); } try { const completionsEqual = await execAsyncFish(`fish-lsp complete | command diff ${completions} -`); if (completionsEqual.stdout.toString().trim() === '') { logger.logToStdout('✓ completions file is up to date'); } else { CheckHealthErrorMessages.completionsFile.notUpToDate(); } } catch (error) { CheckHealthErrorMessages.completionsFile.notUpToDate(); } } catch (error) { CheckHealthErrorMessages.completionsFile.globalNotFound(); } try { logger.logToStdout('\nchecking man page:'); const manFile = await execAsyncFish('man fish-lsp 2>/dev/null | command cat | count'); const manFilePath = (await execAsyncFish('man -w fish-lsp 2> /dev/null')).stdout.toString().trim(); if (manFile.stdout && parseInt(manFile.stdout.toString().trim()) > 1 && manFilePath !== '') { logger.logToStdout(`✓ global man file found: ${manFilePath}`); } else { CheckHealthErrorMessages.manFile.globalNotFound(); } try { const binManFilePath = (await execAsyncFish('path filter -fZ -- $MANPATH/*/fish-lsp.1 | string split0 -m1 -f1')).stdout.toString().trim(); if (binManFilePath !== '') { logger.logToStdout(`✓ binary man file found: ${binManFilePath}`); try { const manDiff = (await execAsyncFish(`fish-lsp info --man-file --show | command diff ${manFilePath} -`)).stdout.toString().trim(); if (manDiff === '') { logger.logToStdout('✓ global man file is up to date'); } else { CheckHealthErrorMessages.manFile.notUpToDate(); } } catch (error) { CheckHealthErrorMessages.manFile.notUpToDate(); } } else { logger.logToStdout('✗ binary man file not found'); } } catch (error) { logger.logToStdout('✗ binary man file not found'); } } catch (error) { CheckHealthErrorMessages.manFile.globalNotFound(); } // Memory usage const memoryUsage = process.memoryUsage(); logger.logToStdout('\nmemory usage:'); logger.logToStdout(` rss: ${Math.round(memoryUsage.rss / 1024 / 1024)} MB`); logger.logToStdout(` heap Used: ${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`); logger.logToStdout(` heap Total: ${Math.round(memoryUsage.heapTotal / 1024 / 1024)} MB`); // System information logger.logToStdout('\nsystem information:'); logger.logToStdout(` platform: ${process.platform}`); logger.logToStdout(` node.js: ${process.version}`); logger.logToStdout(` architecture: ${process.arch}`); logger.logToStdout('\nall checks completed!'); } namespace CheckHealthErrorMessages { export const completionsFile = { notUpToDate: () => { logger.logToStdout('✗ completions file is not up to date'); logger.logToStderr('\nTO UPDATE COMPLETIONS FILE, RUN: '); logger.logToStderr([ '```fish', 'fish-lsp complete > ~/.config/fish/completions/fish-lsp.fish', 'source ~/.config/fish/completions/fish-lsp.fish', '```', ].join('\n')); }, globalNotFound: () => { logger.logToStdout('✗ completions file not found'); logger.logToStderr('\nPLEASE INCLUDE `fish-lsp complete | source` IN YOUR $fish_complete_path\n'); logger.logToStderr('OR RUN:'); logger.logToStderr([ '```fish', 'fish-lsp complete > ~/.config/fish/completions/fish-lsp.fish', 'source ~/.config/fish/completions/fish-lsp.fish', '```', ].join('\n')); }, }; export const manFile = { notUpToDate: () => { logger.logToStdout('✗ global man file is not up to date'); logger.logToStderr('\nTO UPDATE MAN FILE, RUN: '); logger.logToStderr([ '```fish', 'fish-lsp info --man-file --show > $MANPATH[1]/man1/fish-lsp.1', '```', ].join('\n')); }, globalNotFound: () => { logger.logToStdout('✗ global man file not found'); logger.logToStderr('\nPLEASE INCLUDE `fish-lsp info --man-file` IN YOUR $MANPATH, or write it to your $MANPATH `fish-lsp info --man-file --show > $MANPATH[1]/man1/fish-lsp.1`\n'); }, }; } async function logFishLspConfig() { logger.logToStdout('\nfish_lsp_all_indexed_paths:'); const dataDir = env.getFirstValueInArray('__fish_data_dir'); for (const path of config.fish_lsp_all_indexed_paths) { if (!path || path.trim() === '') { logger.logToStdout(`✗ fish-lsp workspace '${path}' is empty or invalid`); continue; } const expanded_path = SyncFileHelper.expandEnvVars(path); if (!expanded_path || expanded_path.trim() === '') { logger.logToStdout(`✗ fish-lsp workspace '${path}' expanded to empty path`); continue; } try { if (fs.statSync(expanded_path).isDirectory()) { logger.logToStdout(`✓ fish-lsp workspace '${path}' is a directory`); } else { logger.logToStdout(`✗ fish-lsp workspace '${path}' is not a directory`); } } catch (error) { logger.logToStdout(`✗ fish-lsp workspace '${path}' (${expanded_path}) stat failed: ${error}`); continue; } try { await fs.promises.access(expanded_path, fs.constants.R_OK); logger.logToStdout(`✓ fish-lsp workspace '${path}' is readable`); } catch (error) { logger.logToStdout(`✗ fish-lsp workspace '${path}' is not readable`); } try { await fs.promises.access(expanded_path, fs.constants.W_OK); logger.logToStdout(`✓ fish-lsp workspace '${path}' is writable`); } catch (error) { if (expanded_path === dataDir) { logger.logToStdout(`✗ fish-lsp workspace '${path}' is not writable (this is expected)`); } else { logger.logToStdout(`✗ fish-lsp workspace '${path}' is not writable`); } } } } function isNodeVersionGreaterThanMinimumRequiredVersion() { const currentVersion = process.versions.node; const currentParsed = DepVersion.extract(currentVersion); if (!currentParsed) { logger.logToStdout(`✗ could not parse current node version: ${currentVersion}`); return false; } const minimumVersion = PkgJson.node; return DepVersion.satisfies(currentParsed, minimumVersion); } ================================================ FILE: src/utils/locations.ts ================================================ // https://github.com/typescript-language-server/typescript-language-server/blob/5a39c1f801ab0cad725a2b8711c0e0d46606a08b/src/utils/typeConverters.ts#L12 import * as LSP from 'vscode-languageserver'; import * as TS from 'web-tree-sitter'; import { equalRanges } from './tree-sitter'; interface Location { line: number; offset: number; } export type TextSpan = { start: Location; end: Location; }; export namespace Range { export const create = (start: LSP.Position, end: LSP.Position): LSP.Range => LSP.Range.create(start, end); export const is = (value: any): value is LSP.Range => LSP.Range.is(value); export const fromTextSpan = (span: TextSpan): LSP.Range => fromLocations(span.start, span.end); export const toTextSpan = (range: LSP.Range): TextSpan => ({ start: Position.toLocation(range.start), end: Position.toLocation(range.end), }); export const fromLocations = (start: Location, end: Location): LSP.Range => LSP.Range.create( Math.max(0, start.line - 1), Math.max(start.offset - 1, 0), Math.max(0, end.line - 1), Math.max(0, end.offset - 1)); export function intersection(one: LSP.Range, other: LSP.Range): LSP.Range | undefined { const start = Position.Max(other.start, one.start); const end = Position.Min(other.end, one.end); if (Position.isAfter(start, end)) { // this happens when there is no overlap: // |-----| // |----| return undefined; } return LSP.Range.create(start, end); } export function isAfter(one: LSP.Range, other: LSP.Range): boolean { return Position.isAfter(one.end, other.end) || Position.isAfter(one.end, other.start) && Position.isBeforeOrEqual(one.start, other.start); } } export namespace Position { export const create = (line: number, character: number): LSP.Position => LSP.Position.create(line, character); export const is = (value: any): value is LSP.Position => LSP.Position.is(value); export const fromLocation = (fishlocation: Location): LSP.Position => { // Clamping on the low side to 0 since Typescript returns 0, 0 when creating new file // even though position is supposed to be 1-based. return { line: Math.max(fishlocation.line - 1, 0), character: Math.max(fishlocation.offset - 1, 0), }; }; export const toLocation = (position: LSP.Position): Location => ({ line: position.line + 1, offset: position.character + 1, }); export function Min(): undefined; export function Min(...positions: LSP.Position[]): LSP.Position; export function Min(...positions: LSP.Position[]): LSP.Position | undefined { if (!positions.length) { return undefined; } let result = positions.pop()!; for (const p of positions) { if (isBefore(p, result)) { result = p; } } return result; } export function isBefore(one: LSP.Position, other: LSP.Position): boolean { if (one.line < other.line) { return true; } if (other.line < one.line) { return false; } return one.character < other.character; } export function Max(): undefined; export function Max(...positions: LSP.Position[]): LSP.Position; export function Max(...positions: LSP.Position[]): LSP.Position | undefined { if (!positions.length) { return undefined; } let result = positions.pop()!; for (const p of positions) { if (isAfter(p, result)) { result = p; } } return result; } export function isAfter(one: LSP.Position, other: LSP.Position): boolean { return !isBeforeOrEqual(one, other); } export function isBeforeOrEqual(one: LSP.Position, other: LSP.Position): boolean { if (one.line < other.line) { return true; } if (other.line < one.line) { return false; } return one.character <= other.character; } export function fromSyntaxNode(node: TS.SyntaxNode): { start: LSP.Position; end: LSP.Position; } { return { start: create(node.startPosition.row, node.endPosition.column), end: create(node.endPosition.row, node.endPosition.column), }; } } export namespace Location { export const create = (uri: string, range: LSP.Range): LSP.Location => LSP.Location.create(uri, range); export const is = (value: any): value is LSP.Location => LSP.Location.is(value); export const fromTextSpan = (resource: LSP.DocumentUri, fishTextSpan: TextSpan): LSP.Location => LSP.Location.create(resource, Range.fromTextSpan(fishTextSpan)); export function equals(one: LSP.Location, other: LSP.Location): boolean { return one.uri === other.uri && Range.is(one.range) && Range.is(other.range) && equalRanges(one.range, other.range); } } ================================================ FILE: src/utils/markdown-builder.ts ================================================ import { MarkupContent, MarkupKind } from 'vscode-languageserver'; /** * Utility function namespace */ export namespace md { export function h(text: string, value: number = 1) { return '#'.repeat(value) + ' ' + text.trim(); } export function italic(value: string) { return `\*${value}\*`; } export function bold(value: string) { return `\*\*${value}\*\*`; } export function boldItalic(value: string) { return `\*\*\*${value}\*\*\*`; } export function separator() { return '___'; } export function space() { return ' '; } export function newline() { // this is for vscode and zed, which both require newlines to be `\n\n` string // temporary fix till client markdown parser can handle single newlines // return '\n\n'; return ' \n'; } export function blockQuote(value: string) { return '> ' + value; } export function inlineCode(value: string) { return '`' + value + '`'; } export function codeBlock(language: string, value: string): string { return [ '```' + language, value, '```', ].join('\n'); } export function li(value: string) { return '- ' + value; } export function ol(value: string) { return '1.' + value; } export function link(name: string, href: string) { return `[${name}](${href})`; } export function filepathString(value: string) { return escapeMarkdownSyntaxTokens(value); } export function p(...strs: string[]) { return strs.join(space()); } } // https://github.com/typescript-language-server/typescript-language-server/blob/master/src/utils/MarkdownString.ts export const enum MarkdownStringTextNewlineStyle { Paragraph = 0, Break = 1, } export class MarkdownBuilder { constructor(public value = '') { } appendText(value: string, newlineStyle: MarkdownStringTextNewlineStyle = MarkdownStringTextNewlineStyle.Paragraph): MarkdownBuilder { const escaped = escapeMarkdownSyntaxTokens(value); const spacesNormalized = escaped.replace(/([ \t]+)/g, (_match, g1) => ' '.repeat(g1.length)); const newlineSep = newlineStyle === MarkdownStringTextNewlineStyle.Break ? '\\\n' : '\n\n'; this.value += spacesNormalized.split('\n').join(newlineSep); return this; } appendNewline(): MarkdownBuilder { this.value += md.newline(); return this; } /** * arguments are either a markdown string, or an array of markdown strings: * - if argument is a string, consider the argument it's own line of the output * - if argument is an array, join it's items as space separated items */ fromMarkdown(...values: (string | string[])[]): MarkdownBuilder { this.value += values.map(item => Array.isArray(item) ? item.map(i => i.trim()).join(' ') : item.trim(), ).join('\n'); return this; } appendMarkdown(value: string): MarkdownBuilder { this.value += value; return this; } appendCodeblock(langId: string, code: string): MarkdownBuilder { this.value += '\n```'; this.value += langId; this.value += '\n'; this.value += code; this.value += '\n```\n'; return this; } toMarkupContent(): MarkupContent { return { kind: MarkupKind.Markdown, value: this.value, }; } toString() { return this.value; } } export function escapeMarkdownSyntaxTokens(text: string): string { // escape backslashes first to avoid double-escaping const str = text.replace(/\\/g, '\\\\'); // escape markdown syntax tokens: http://daringfireball.net/projects/markdown/syntax#backslash return str.replace(/[`*_{}[\]()#+\-!>]/g, '\\$&'); } ================================================ FILE: src/utils/maybe.ts ================================================ /** * Optional/Maybe monad for null-safe operations and functional composition * * Provides a way to safely chain operations that might return null/undefined * without explicit null checking at each step. * * @example * ```typescript * // Instead of: * const parent = node.parent; * if (!parent) return false; * const condition = parent.childForFieldName('condition'); * return condition?.equals(node) || false; * * // Use: * return Maybe.of(node.parent) * .flatMap(p => Maybe.of(p.childForFieldName('condition'))) * .equals(node); * ``` */ export class Maybe { constructor(private value: T | null | undefined) {} /** * Create a Maybe from a potentially null/undefined value */ static of(value: T | null | undefined): Maybe { return new Maybe(value); } /** * Create an empty Maybe */ static none(): Maybe { return new Maybe(null); } /** * Transform the value if present */ map(fn: (value: T) => U | null | undefined): Maybe { return this.value ? Maybe.of(fn(this.value)) : Maybe.none(); } /** * Chain Maybe operations (flatMap/bind) */ flatMap(fn: (value: T) => Maybe): Maybe { return this.value ? fn(this.value) : Maybe.none(); } /** * Filter the value based on a predicate */ filter(predicate: (value: T) => boolean): Maybe { return !!this.value && predicate(this.value) ? this : Maybe.none(); } /** * Get the value or return a default */ getOrElse(defaultValue: T): T; getOrElse(defaultValue: U): T | U; getOrElse(defaultValue: T | U): T | U { return this.value ? this.value : defaultValue; } /** * Check if the Maybe contains a value */ exists(): boolean { return !!this.value; } /** * Check if the contained value equals another value (using .equals method if available) */ equals(other: T): boolean { if (!this.value) return false; if (typeof this.value === 'object' && 'equals' in this.value && typeof this.value.equals === 'function') { return (this.value as any).equals(other); } return this.value === other; } /** * Execute a side effect if the value exists */ ifPresent(action: (value: T) => void): Maybe { if (this.value) { action(this.value); } return this; } /** * Get the raw value (use with caution) */ get(): T | null | undefined { return this.value; } } ================================================ FILE: src/utils/node-types.ts ================================================ import { SyntaxNode } from 'web-tree-sitter'; import { getLeafNodes } from './tree-sitter'; import { isDefinitionName, isEmittedEventDefinitionName, VariableDefinitionKeywords } from '../parsing/barrel'; import { Option, isMatchingOption, isMatchingOptionOrOptionValue, isMatchingOptionValue } from '../parsing/options'; import { isVariableDefinitionName, isFunctionDefinitionName, isAliasDefinitionName, isExportVariableDefinitionName, isArgparseVariableDefinitionName } from '../parsing/barrel'; import { isBuiltin as checkBuiltinName, BuiltInList } from './builtins'; import { PrebuiltDocumentationMap } from './snippets'; // use the `../parsing/barrel` barrel file's imports for finding the definition names export { isVariableDefinitionName, isFunctionDefinitionName, isAliasDefinitionName, isExportVariableDefinitionName, isArgparseVariableDefinitionName, isEmittedEventDefinitionName, isDefinitionName, }; /** * checks if a node is a variable definition. Current syntax tree from tree-sitter-fish will * only tokenize variable names if they are defined in a for loop. Otherwise, they are tokenized * with the node type of 'name'. * * @param {SyntaxNode} node - the node to check if it is a variable definition * @returns {boolean} true if the node is a variable definition, false otherwise */ export function isVariableDefinition(node: SyntaxNode): boolean { return isVariableDefinitionName(node); } /** * fish shell comment: '# ...' */ export function isComment(node: SyntaxNode): boolean { return node.type === 'comment' && !isShebang(node); } export function isShebang(node: SyntaxNode) { const parent = node.parent; if (!parent || !isProgram(parent)) { return false; } if (node.startPosition.row !== 0) { return false; } const firstLine = parent.firstChild; if (!firstLine) { return false; } if (!node.equals(firstLine)) { return false; } return ( firstLine.type === 'comment' && firstLine.text.startsWith('#!') && firstLine.text.includes('fish') ); } /** * function some_fish_func * ... * end * @see isFunctionDefinitionName() */ export function isFunctionDefinition(node: SyntaxNode): boolean { return node.type === 'function_definition'; } /** * checks for all fish types of SyntaxNodes that are commands. * This includes: `command`, `test_command`, and `command_substitution`. */ export function isCommand(node: SyntaxNode): boolean { return [ 'command', 'test_command', 'command_substitution', ].includes(node.type); } export function isFishShippedFunctionName(node: SyntaxNode): boolean { return !!PrebuiltDocumentationMap.getByType('command').find((item) => { if (item.name === node.text) { return true; } return false; }); } /** * Checks if a node is a top level function definition. Nodes can be either: * - `node.type === 'function_definition'` * - `node.parent.type === 'function_definition' && node.type === 'word' && node.parent.firstChild.eqauls(node)` * This is used to determine if a function is defined inside another function or at the top level of a script. * ___ * ```fish * #### T === TRUE && F === FALSE * function top_level_function_1; end; * # ^-- T ^-- T * if status is-interactive * function top_level_function_2; end; * # ^-- T ^-- T * function top_level_function_3 * # ^-- T ^-- T * function not_top_level_function; end; * # ^-- F ^-- F * end * end * ``` * ___ * @param {SyntaxNode} node - the node to check if it is a top level function definition * @returns {boolean} true if the node is a top level function definition, false otherwise */ export function isTopLevelFunctionDefinition(node: SyntaxNode): boolean { if (isFunctionDefinition(node)) { return !!(node.parent && isTopLevelDefinition(node.parent)); } if (isFunctionDefinitionName(node)) { return !!(node.parent && node.parent.parent && isTopLevelDefinition(node.parent.parent)); } return false; } export function isTopLevelDefinition(node: SyntaxNode): boolean { let currentNode: SyntaxNode | null = node; while (currentNode) { if (!currentNode) break; if (isProgram(currentNode)) { return true; } if (isFunctionDefinition(currentNode)) { return false; } currentNode = currentNode.parent; } return true; } /** * isVariableDefinitionName() || isFunctionDefinitionName() */ export function isDefinition(node: SyntaxNode): boolean { return isFunctionDefinitionName(node) || isVariableDefinitionName(node); } /** * checks if a node is the firstNamedChild of a command */ export function isCommandName(node: SyntaxNode): boolean { const parent = node.parent || node; const cmdName = parent?.firstNamedChild || node?.firstNamedChild; if (!parent || !cmdName) { return false; } if (!isCommand(parent)) { return false; } return node.type === 'word' && node.equals(cmdName); } /** * the root node of a fish script */ export function isProgram(node: SyntaxNode): boolean { return node.type === 'program' || node.parent === null; } export function isError(node: SyntaxNode | null = null): boolean { if (node) { return node.type === 'ERROR'; } return false; } export function isForLoop(node: SyntaxNode): boolean { return node.type === 'for_statement'; } export function isIfStatement(node: SyntaxNode): boolean { return node.type === 'if_statement'; } export function isElseStatement(node: SyntaxNode): boolean { return node.type === 'else_clause'; } // strict check for if statement or else clauses export function isConditional(node: SyntaxNode): boolean { return ['if_statement', 'else_if_clause', 'else_clause'].includes(node.type); } export function isIfOrElseIfConditional(node: SyntaxNode): boolean { return ['if_statement', 'else_if_clause'].includes(node.type); } export function isPossibleUnreachableStatement(node: SyntaxNode): boolean { if (isIfStatement(node)) { return node.lastNamedChild?.type === 'else_clause'; } else if (node.type === 'for_statement') { return true; } else if (node.type === 'switch_statement') { return false; } return false; } export function isClause(node: SyntaxNode): boolean { return [ 'case_clause', 'else_clause', 'else_if_clause', ].includes(node.type); } /** * statements contain clauses */ export function isStatement(node: SyntaxNode): boolean { return [ 'for_statement', 'switch_statement', 'while_statement', 'if_statement', 'begin_statement', ].includes(node.type); } /** * since statement SyntaxNodes contains clauses, treats statements and clauses the same: * if ... - if_statement * else if ... --- else_if_clause * else ... --- else_clause * end; */ export function isBlock(node: SyntaxNode): boolean { return isClause(node) || isStatement(node); } export function isEnd(node: SyntaxNode): boolean { return node.type === 'end'; } /** * Any SyntaxNode that will enclose a new local scope: * Program, Function, if, for, while */ export function isScope(node: SyntaxNode): boolean { return isProgram(node) || isFunctionDefinition(node) || isStatement(node); } export function isSemicolon(node: SyntaxNode): boolean { return node.type === ';' && node.text === ';'; } export function isNewline(node: SyntaxNode): boolean { return node.type === '\n'; } export function isBlockBreak(node: SyntaxNode): boolean { return isEnd(node) || isSemicolon(node) || isNewline(node); } export function isString(node: SyntaxNode) { return [ 'double_quote_string', 'single_quote_string', ].includes(node.type); } export function isStringCharacter(node: SyntaxNode) { return [ "'", '"', ].includes(node.type); } export function isEmptyString(node: SyntaxNode) { return isString(node) && node.text.length === 2; } /** * Checks if a node is fish's end stdin token `--` * This is used to signal the end of stdin input, like in the argparse command: `argparse h/help -- $argv` * @param {SyntaxNode} node - the node to check * @returns true if the node is the end stdin token */ export function isEndStdinCharacter(node: SyntaxNode) { return '--' === node.text && node.type === 'word'; } /** * Checks if a node is fish escape sequence token `\` character * This token will be used to escape commands which span multiple lines */ export function isEscapeSequence(node: SyntaxNode) { return node.type === 'escape_sequence'; } export function isLongOption(node: SyntaxNode): boolean { return node.text.startsWith('--') && !isEndStdinCharacter(node); } /** * node.text !== '-' because `-` this would not be an option... Consider the case: * ``` * cat some_file | nvim - * ``` */ export function isShortOption(node: SyntaxNode): boolean { return node.text.startsWith('-') && !isLongOption(node) && node.text !== '-'; } /** * Checks if a node is an option/switch/flag in any of the following formats: * - short options: `-g`, `-f1`, `-f 1`, `-f=2`, `-gx` * - long options: `--global`, `--file`, `--file=1`, `--file 1` * - old unix style flags: `-type`, `-type=file` * @param {SyntaxNode} node - the node to check * @returns {boolean} true if the node is an option */ export function isOption(node: SyntaxNode): boolean { if (isEndStdinCharacter(node)) return false; return isShortOption(node) || isLongOption(node); } export function isOptionValue(node: SyntaxNode): boolean { if (isEndStdinCharacter(node)) return false; if (isDefinitionName(node)) return false; if (!node.parent) return false; if (isOption(node) && node.text.includes('=') && node.type === 'word') { return true; } if (isString(node) && node.previousNamedSibling && isOption(node.previousNamedSibling)) { return true; } if (node.type === 'word' && node.previousSibling && isOption(node.previousSibling)) { return true; } return false; } /** careful not to call this on old unix style flags/options */ export function isJoinedShortOption(node: SyntaxNode) { if (isLongOption(node)) return false; return isShortOption(node) && node.text.slice(1).length > 1; } /** careful not to call this on old unix style flags/options */ export function hasShortOptionCharacter(node: SyntaxNode, findChar: string) { if (isLongOption(node)) return false; return isShortOption(node) && node.text.slice(1).includes(findChar); } export { isMatchingOption, findMatchingOptions } from '../parsing/options'; export function isPipe(node: SyntaxNode): boolean { return node.type === 'pipe'; } // Makes sure that the node we are assuming is a variable name (for a command that creates a variable definition from its arguments) // is not a token that fish uses for other purposes, like `-`, `--`, `\\`, `;`, or `(` export function isInvalidVariableName(node: SyntaxNode): boolean { switch (node.text.trim()) { case '': case '-': case '--': case '\\': case ';': case '(': return true; // these are not valid variable names default: return false; // all other names are valid } } export function gatherSiblingsTillEol(node: SyntaxNode): SyntaxNode[] { const siblings = []; let next = node.nextSibling; while (next && !isNewline(next)) { siblings.push(next); next = next.nextSibling; } return siblings; } /* * Checks for nodes which should stop the search for * command nodes, used in findParentCommand() */ export function isBeforeCommand(node: SyntaxNode) { return [ 'file_redirect', 'redirect', 'redirected_statement', 'conditional_execution', 'stream_redirect', 'pipe', ].includes(node.type) || isFunctionDefinition(node) || isStatement(node) || isSemicolon(node) || isNewline(node) || isEnd(node); } export function isVariableExpansion(node: SyntaxNode) { return node.type === 'variable_expansion'; } /** * Checks for variable expansions that match the variable name, DONT PASS `variableName` with leading `$` * @param {SyntaxNode} node - the node to check * @param {string} variableName - the name of the variable to check for (`pipestatus`, `status`, `argv`, ...) * @returns {boolean} true if the node is a variable expansion matching the name */ export function isVariableExpansionWithName(node: SyntaxNode, variableName: string): boolean { return node.type === 'variable_expansion' && node.text === `$${variableName}`; } export function isVariable(node: SyntaxNode) { if (isVariableDefinition(node)) { return true; } else { return ['variable_expansion', 'variable_name'].includes(node.type); } } export function isCompleteFlagCommandName(node: SyntaxNode) { if (node.parent && isCommandWithName(node, 'set')) { const children = node.parent.childrenForFieldName('arguments').filter(n => !isOption(n)); if (children && children.at(0)?.equals(node)) { return node.text.startsWith('_flag_'); } } return false; } /** * finds the parent command of the current node * * @param {SyntaxNode} node - the node to check for its parent * @returns {SyntaxNode | null} command node or null */ export function findPreviousSibling(node?: SyntaxNode): SyntaxNode | null { let currentNode: SyntaxNode | null | undefined = node; if (!currentNode) { return null; } while (currentNode !== null) { if (isCommand(currentNode)) { return currentNode; } currentNode = currentNode.parent; } return null; } /** * finds the parent command of the current node * * @param {SyntaxNode} node - the node to check for its parent * @returns {SyntaxNode | null} command node or null */ export function findParentCommand(node?: SyntaxNode): SyntaxNode | null { let currentNode: SyntaxNode | null | undefined = node; if (!currentNode) { return null; } while (currentNode !== null) { if (currentNode && isCommand(currentNode)) { return currentNode; } currentNode = currentNode.parent; } return null; } export function isConcatenation(node: SyntaxNode) { return node.type === 'concatenation'; } export function isAliasWithName(node: SyntaxNode, aliasName: string) { if (isAliasDefinitionName(node)) { return node.text.split('=').at(0) === aliasName; } return false; } /** * finds the parent function of the current node * * @param {SyntaxNode} node - the node to check for its parent * @returns {SyntaxNode | null} command node or null */ export function findParentFunction(node?: SyntaxNode): SyntaxNode | null { let currentNode: SyntaxNode | null | undefined = node; if (!currentNode) { return null; } while (currentNode !== null) { if (isFunctionDefinition(currentNode)) { return currentNode; } currentNode = currentNode.parent; } return null; } export function findParentVariableDefinitionKeyword(node?: SyntaxNode): SyntaxNode | null { if (!node || !isVariableDefinitionName(node)) return null; const currentNode: SyntaxNode | null | undefined = node; const parent = currentNode?.parent; if (!currentNode || !parent) { return null; } const varKeyword = parent.firstChild?.text.trim() || ''; if (!varKeyword) { return null; } if (VariableDefinitionKeywords.includes(varKeyword)) { return parent; } return null; } export function findForLoopVariable(node: SyntaxNode): SyntaxNode | null { for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; if (child?.type === 'variable_name') { return child; } } return null; } /** * @param {SyntaxNode} node - finds the node in a fish command that will * contain the variable definition * * @return {SyntaxNode | null} variable node that was found **/ export function findSetDefinedVariable(node: SyntaxNode): SyntaxNode | null { const parent = findParentCommand(node); if (!parent) { return null; } const children: SyntaxNode[] = parent.children; let i = 1; let child: SyntaxNode = children[i]!; while (child !== undefined) { if (!child.text.startsWith('-')) { return child; } if (i === children.length - 1) { return null; } child = children[i++]!; } return child; } export function hasParent(node: SyntaxNode, callbackfn: (n: SyntaxNode) => boolean) { let currentNode: SyntaxNode = node; while (currentNode !== null) { if (callbackfn(currentNode)) { return true; } currentNode = currentNode.parent!; } return false; } export function findParent(node: SyntaxNode, callbackfn: (n: SyntaxNode) => boolean) { let currentNode: SyntaxNode = node; while (currentNode !== null) { if (callbackfn(currentNode)) { return currentNode; } currentNode = currentNode.parent!; } return null; } /** * Find the parent node that matches the callback function, or return the root node of the tree */ export function findParentWithFallback(node: SyntaxNode, callbackfn: (n: SyntaxNode) => boolean) { let currentNode: SyntaxNode | null = node; while (currentNode !== null) { if (callbackfn(currentNode)) { return currentNode; } currentNode = currentNode.parent; } return node.tree.rootNode; } export function hasParentFunction(node: SyntaxNode) { let currentNode: SyntaxNode = node; while (currentNode !== null) { if (isFunctionDefinition(currentNode) || currentNode.type === 'function') { return true; } if (currentNode.parent === null) { return false; } currentNode = currentNode?.parent; } return false; } export function findFunctionScope(node: SyntaxNode) { while (node.parent !== null) { if (isFunctionDefinition(node)) { return node; } node = node.parent; } return node; } // node1 encloses node2 export function scopeCheck(node1: SyntaxNode, node2: SyntaxNode): boolean { const scope1 = findFunctionScope(node1); const scope2 = findFunctionScope(node2); if (isProgram(scope1)) { return true; } return scope1 === scope2; } export function wordNodeIsCommand(node: SyntaxNode) { if (node.type !== 'word') { return false; } return node.parent ? isCommand(node.parent) && node.parent.firstChild?.text === node.text : false; } export function isSwitchStatement(node: SyntaxNode) { return node.type === 'switch_statement'; } export function isCaseClause(node: SyntaxNode) { return node.type === 'case_clause'; } export function isReturn(node: SyntaxNode) { return node.type === 'return' && node.firstChild?.text === 'return'; } export function isExit(node: SyntaxNode) { return node.type === 'command' && node.firstChild?.text === 'exit'; } export function isConditionalCommand(node: SyntaxNode) { return node.type === 'conditional_execution'; } export function isCommandFlag(node: SyntaxNode) { return [ 'test_option', 'word', 'escape_sequence', ].includes(node.type) || node.text.startsWith('-') || findParentCommand(node) !== null; } export function isRegexArgument(n: SyntaxNode): boolean { return n.text === '--regex' || n.text === '-r'; } export function isUnmatchedStringCharacter(node: SyntaxNode) { if (!isStringCharacter(node)) { return false; } if (node.parent && isString(node.parent)) { return false; } return true; } export function isPartialForLoop(node: SyntaxNode) { const semiCompleteForLoop = ['for', 'i', 'in', '_']; const errorNode = node.parent; if (node.text === 'for' && node.type === 'for') { if (!errorNode) { return true; } if (getLeafNodes(errorNode).length < semiCompleteForLoop.length) { return true; } return false; } if (!errorNode) { return false; } return ( errorNode.hasError && errorNode.text.startsWith('for') && !errorNode.text.includes(' in ') ); } export function isInlineComment(node: SyntaxNode) { if (!isComment(node)) return false; const previousSibling: SyntaxNode | undefined | null = node.previousNamedSibling; if (!previousSibling) return false; return previousSibling?.startPosition.row === node.startPosition.row && previousSibling?.type !== 'comment'; } export function isCommandWithName(node: SyntaxNode, ...commandNames: string[]) { if (node.type !== 'command') return false; return !!node.firstChild && commandNames.includes(node.firstChild.text); } export function isArgumentThatCanContainCommandCalls(node: SyntaxNode) { if ( isDefinitionName(node) || isCommand(node) || isCommandName(node) || !node.isNamed ) return false; // if (!isString(node) || node.type !== 'word') return false; const parent = findParent(node, (n) => isCommand(n) || isFunctionDefinition(n)); if (!parent) return false; if (isFunctionDefinition(parent)) { return isMatchingOptionValue(node, Option.create('-w', '--wraps').withValue()); } const commandName = parent.firstNamedChild?.text; if (!commandName) return false; switch (commandName) { case 'complete': return isMatchingOptionValue(node, Option.create('-w', '--wraps').withValue()) || isMatchingOptionValue(node, Option.create('-c', '--command').withValue()) || isMatchingOptionValue(node, Option.create('-a', '--arguments').withValue()) || isMatchingOptionValue(node, Option.create('-n', '--condition').withValue()); case 'alias': case 'bind': return true; case 'abbr': return isMatchingOptionValue(node, Option.create('-f', '--function').withValue()) || isMatchingOptionValue(node, Option.create('-c', '--command').withValue()); case 'argparse': return isMatchingOptionValue(node, Option.create('-n', '--name').withValue()); default: return false; } } export function isStringWithCommandCall(node: SyntaxNode) { if (!isString(node)) return false; // currently there is only TWO different types parent nodes, that we consider some //of their string children to contain references to command/function calls const parent = findParent(node, (n) => isFunctionDefinition(n) || isCommand(n)); if (!parent) return false; // when a function definition contains the `--wraps`/`-w` option, if (isFunctionDefinition(parent)) { return isMatchingOptionOrOptionValue(node, Option.create('-w', '--wraps').withValue()); } // when a command is `complete`, `alias`, or `bind` command, we check for the options that are allowed if (isCommand(parent)) { const parentCommandName = parent.firstChild?.text; if (!parentCommandName) return false; switch (parentCommandName) { case 'complete': return isMatchingOptionOrOptionValue(node, Option.create('-w', '--wraps').withValue()) || isMatchingOptionOrOptionValue(node, Option.create('-c', '--command').withValue()) || isMatchingOptionOrOptionValue(node, Option.create('-a', '--arguments').withValue()) || isMatchingOptionOrOptionValue(node, Option.create('-n', '--condition').withValue()); // note: both of these cases are considered matches since any node string argument // passed in must be an argument after the "definition" node case 'alias': case 'bind': return true; case 'abbr': return isMatchingOptionOrOptionValue(node, Option.create('-f', '--function').withValue()); } } return false; } export function isReturnStatusNumber(node: SyntaxNode) { if (node.type !== 'integer') return false; const parent = node.parent; if (!parent) return false; return parent.type === 'return'; } export function isConcatenatedValue(node: SyntaxNode) { if (!['word', 'variable_expansion', 'brace_expansion', 'integer', 'concatenation'].includes(node.type)) return false; if (node.type === 'concatenation') return true; const parent = findParent(node, isConcatenation); if (!parent) return false; return true; } export function isBraceExpansion(node: SyntaxNode) { return node.type === 'brace_expansion'; } /** * Check if a node represents a file path (with filename modifier) * This matches the exact logic from the original addPathTokensToArray function. * * Matches: * - Absolute paths with file extensions that don't end with /: /path/to/file.txt * - Relative filenames with extensions (no path separators): config.fish, file.txt */ export function isFilepath(node: SyntaxNode): boolean { // home_dir_expansion nodes are always treated as directory paths in the original if (node.type === 'home_dir_expansion') { return false; } if (node.type !== 'word') { return false; } const text = node.text; // Detect absolute paths that start with / if (text.match(/^\/[a-zA-Z0-9_\-\/\.]+/)) { // Original logic: it's a filename if it has an extension AND doesn't end with / const hasExtension = text.match(/\.[a-zA-Z0-9]+$/); const endsWithSlash = text.endsWith('/'); return hasExtension !== null && !endsWithSlash; } // Detect relative paths with file extensions (no leading slash or ~) // Pattern: word characters, dash, underscore, then dot, then extension if (text.match(/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/)) { return true; } return false; } /** * Check if a node represents a directory path (with path modifier) * This matches the exact logic from the original addPathTokensToArray function. * * Matches: * - Absolute paths without file extension or ending with /: /path/to/dir, /usr/bin/ * - Home directory paths: ~, ~/dir, ~/path/to/dir * - home_dir_expansion nodes */ export function isDirectoryPath(node: SyntaxNode): boolean { // home_dir_expansion nodes are always directory paths if (node.type === 'home_dir_expansion') { return true; } if (node.type !== 'word') { return false; } const text = node.text; // Detect home directory paths: ~ or ~/something if (text.match(/^~(\/[a-zA-Z0-9_\-\/\.]*)?$/)) { return true; } // Detect absolute paths that start with / if (text.match(/^\/[a-zA-Z0-9_\-\/\.]+/)) { // Original logic: it's a directory path if it's NOT a filename // A filename has an extension AND doesn't end with / const hasExtension = text.match(/\.[a-zA-Z0-9]+$/); const endsWithSlash = text.endsWith('/'); const isFilename = hasExtension !== null && !endsWithSlash; return !isFilename; } return false; } /** * Check if a node represents any kind of path (file or directory) */ export function isPathNode(node: SyntaxNode): boolean { return isFilepath(node) || isDirectoryPath(node) || node.text.includes('/') && node.type === 'word'; } export function isBuiltin(node: SyntaxNode) { return isCommandWithName(node, ...BuiltInList); } export function isCompleteCommandName(node: SyntaxNode) { if (!node.parent || !isCommand(node.parent)) return false; if (!isCommandWithName(node.parent, 'complete')) return false; const previousSibling = node.previousNamedSibling; if (!previousSibling) return false; if (isMatchingOption(previousSibling, Option.create('-c', '--command').withValue())) { return !isOption(node); } return false; } /** * Checks if a command name is a built-in fish command */ export function isBuiltinCommand(node: SyntaxNode): boolean { if (!isCommand(node)) return false; const commandName = node.firstNamedChild; if (!commandName || !isCommandName(commandName)) return false; return checkBuiltinName(commandName.text); } /** * Checks if a node is a redirection (stream_redirect or file_redirect) */ export function isRedirect(n: SyntaxNode): boolean { // current grammar names we care about return n.type === 'stream_redirect' || n.type === 'file_redirect'; } /** * For file_redirect, return only the operator child (direction) * For stream_redirect, return the whole node (covers cases like >&2) * * If the grammar changes (e.g. adds a specific child for stream_redirect), * just swap the logic here without touching the handler. */ export function getRedirectOperatorNode(n: SyntaxNode): SyntaxNode | null { if (n.type === 'file_redirect') { // Tree-sitter fish exposes the operator as a named child of type "direction" // Example from your AST: // (file_redirect // operator: (direction) ; [1, 12] - [1, 13] // destination: (word)) const op = n.namedChildren.find((c) => c.type === 'direction'); return op ?? null; } if (n.type === 'stream_redirect') { // Example from your AST (no child details shown): // redirect: (stream_redirect) ; [0, 12] - [0, 15] -> ">&2" // Using the whole node as the operator token meets your requirement return n; } return null; } ================================================ FILE: src/utils/path-resolution.ts ================================================ import path, { resolve, dirname } from 'path'; import { realpathSync } from 'fs'; import { vfs } from '../virtual-fs'; import { SyncFileHelper } from './file-operations'; /** * Centralized path resolution utilities for handling bundled vs development environments * Uses embedded paths from build-time when available, with clean fallbacks to standard locations */ /** * Finds the first existing file from an array of possible file paths * @param possiblePaths File paths to check * @returns The first path that exists as a file, or undefined if none exist */ export function findFirstExistingFile(...possiblePaths: string[]): string | undefined { for (const path of possiblePaths) { if (SyncFileHelper.exists(path) && SyncFileHelper.isFile(path)) { return path; } } return undefined; } /** * Helper function to check if a path exists and is a file * @param path The path to check * @returns True if the path exists and is a file */ export function isExistingFile(path: string): boolean { try { return SyncFileHelper.exists(path) && SyncFileHelper.isFile(path); } catch { return false; } } /** * Check if we're running in a bundled environment */ export function isBundledEnvironment(): boolean { // Use environment variable injected at build time, or check if we don't have __dirname return !!process.env.FISH_LSP_BUNDLED || typeof __dirname === 'undefined'; } /** * Get the current executable path */ export function getCurrentExecutablePath(): string { if (process.argv[1]) { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } } // For library imports, use the current module's directory or process executable return typeof __filename !== 'undefined' ? __filename : process.execPath; } /** * Get the correct project root path for both bundled and development versions * Dynamically resolves from current working directory instead of hardcoded paths */ export function getProjectRootPath(): string { // For bundled mode, always use current working directory (where binary is executed) if (isBundledEnvironment()) { if (getCurrentExecutablePath().endsWith('dist/fish-lsp')) { return resolve(path.dirname(getCurrentExecutablePath()), '..'); } else { return resolve(path.basename(getCurrentExecutablePath())); } } // For development mode, try to detect project root from executable location const execPath = getCurrentExecutablePath(); // For development binary in dist directory, bin directory (wrapper), or out directory if (execPath.includes('/dist/') || execPath.includes('/bin/') || execPath.includes('/out/')) { if (execPath.includes('/bin/') || execPath.includes('/dist/')) { return resolve(dirname(execPath), '..'); } if (execPath.includes('/out/')) { return resolve(dirname(execPath), '..'); } } // Fallback: use __dirname resolution for development return typeof __dirname !== 'undefined' ? resolve(__dirname, '..', '..') : resolve(path.dirname(process.execPath)); } /** * Get fish build time file path for bundled and development versions, note that * this a generated build-time.json file should be used if available, otherwise * fallback to standard bundled location */ export function getFishBuildTimeFilePath(): string { // Check for out/build-time.json first (created by postinstall - shows installation time) const outBuildTimePath = resolve(getProjectRootPath(), 'out', 'build-time.json'); if (outBuildTimePath && isExistingFile(outBuildTimePath)) { return outBuildTimePath; } // Fallback to root build-time.json if it exists const localBuildTimePath = resolve(getProjectRootPath(), 'build-time.json'); if (localBuildTimePath && isExistingFile(localBuildTimePath)) { return localBuildTimePath; } // Final fallback to embedded build-time.json (shows publish time) return vfs.getPathOrFallback( 'out/build-time.json', resolve(getProjectRootPath(), 'out', 'build-time.json'), ); } /** * Get man file path for bundled and development versions */ export function getManFilePath(): string { const existing = resolve(getProjectRootPath(), 'man', 'fish-lsp.1'); if (existing && isExistingFile(existing)) { return existing; } // Support legacy path structure as fallback const legacyExisting = resolve(getProjectRootPath(), 'man', 'man1', 'fish-lsp.1'); if (legacyExisting && isExistingFile(legacyExisting)) { return legacyExisting; } // Fallback to VFS if available, otherwise return the expected path if (vfs && typeof vfs.getPathOrFallback === 'function') { try { return vfs.getPathOrFallback( 'man/fish-lsp.1', resolve(getProjectRootPath(), 'man', 'fish-lsp.1'), resolve(getProjectRootPath(), 'man', 'man1', 'fish-lsp.1'), ); } catch { // VFS not available or file not found, return expected path } } // Final fallback - return the expected path even if file doesn't exist return resolve(getProjectRootPath(), 'man', 'fish-lsp.1'); } ================================================ FILE: src/utils/polyfills.ts ================================================ // Polyfills for array methods missing in Node.js 18 // These methods were added in later versions of Node.js/JavaScript if (!Array.prototype.toReversed) { Array.prototype.toReversed = function(this: T[]): T[] { return [...this].reverse(); }; } if (!Array.prototype.toSorted) { Array.prototype.toSorted = function(this: T[], compareFn?: (a: T, b: T) => number): T[] { return [...this].sort(compareFn); }; } if (!Array.prototype.toSpliced) { Array.prototype.toSpliced = function(this: T[], start: number, deleteCount?: number, ...items: T[]): T[] { const result = [...this]; result.splice(start, deleteCount ?? result.length - start, ...items); return result; }; } if (!Array.prototype.with) { Array.prototype.with = function(this: T[], index: number, value: T): T[] { const result = [...this]; result[index] = value; return result; }; } if (!Array.prototype.at) { Array.prototype.at = function(this: T[], index: number): T | undefined { const len = this.length; const relativeIndex = Math.trunc(index) || 0; const k = relativeIndex >= 0 ? relativeIndex : len + relativeIndex; return k >= 0 && k < len ? this[k] : undefined; }; } // string prototype extensions declare global { interface String { /** * Split string by newlines into an array * @returns Array of lines */ splitNewlines(): string[]; /** * Split string by newlines and trim each line * @returns Array of trimmed lines */ splitNewlinesTrimmed(): string[]; } } if (!String.prototype.splitNewlines) { String.prototype.splitNewlines = function(this: string): string[] { return this.split('\n'); }; } if (!String.prototype.splitNewlinesTrimmed) { String.prototype.splitNewlinesTrimmed = function(this: string): string[] { return this.split('\n').map(s => s.trim()); }; } // Export empty object to make this a module export {}; ================================================ FILE: src/utils/process-env.ts ================================================ import { join } from 'path'; import { existsSync } from 'fs'; import { PrebuiltDocumentationMap } from './snippets'; import { md } from './markdown-builder'; import { env } from './env-manager'; import { ExecFishFiles } from './exec'; export const autoloadedFishVariableNames = [ '__fish_bin_dir', '__fish_config_dir', '__fish_data_dir', '__fish_help_dir', '__fish_initialized', // docs unclear: https://fishshell.com/docs/current/language.html#syntax-function-autoloading // includes __fish_sysconfdir but __fish_sysconf_dir is defined on local system '__fish_sysconfdir', '__fish_sysconf_dir', '__fish_user_data_dir', '__fish_added_user_paths', '__fish_vendor_completionsdirs', '__fish_vendor_confdirs', '__fish_vendor_functionsdirs', 'fish_function_path', 'fish_complete_path', 'fish_user_paths', ] as const; export type AutoloadedFishVariableName = typeof autoloadedFishVariableNames[number]; export let hasAutoloadedFishVariables = false; export async function setupProcessEnvExecFile() { if (hasAutoloadedFishVariables) return autoloadedFishVariableNames; try { const result = await ExecFishFiles.getFishAutoloadedPaths(); if (result.stderr) { process.stderr.write(`[WARN] fish script stderr: ${result.stderr}\n`); } result.stdout.split('\n').forEach(line => { if (line.trim()) { const [variable, value]: [AutoloadedFishVariableName, string] = line.split('\t') as [AutoloadedFishVariableName, string]; if (variable) { const storeValue = value ? value.trim() : undefined; env.set(variable.trim(), storeValue); } } }); } catch (error) { process.stderr.write(`[ERROR] retrieving autoloaded fish env variables failure: ${error}\n`); // Fallback: set basic default paths setupFallbackProcessEnv(); } hasAutoloadedFishVariables = true; return autoloadedFishVariableNames; } function setupFallbackProcessEnv() { // Set basic fallback values when fish script execution fails const homeDir = process.env.HOME || '/tmp'; const fishBin = process.env.FISH_BIN || '/usr/bin/fish'; const fishPrefix = fishBin.replace(/\/bin\/fish$/, ''); env.set('__fish_bin_dir', `${fishPrefix}/bin`); env.set('__fish_config_dir', `${homeDir}/.config/fish`); env.set('__fish_data_dir', `${fishPrefix}/share/fish`); env.set('__fish_help_dir', `${fishPrefix}/share/doc/fish`); env.set('__fish_sysconf_dir', `${fishPrefix}/etc/fish`); env.set('__fish_user_data_dir', `${homeDir}/.local/share/fish`); env.set('__fish_vendor_completionsdirs', `${fishPrefix}/share/fish/vendor_completions.d`); env.set('__fish_vendor_confdirs', `${fishPrefix}/share/fish/vendor_conf.d`); env.set('__fish_vendor_functionsdirs', `${fishPrefix}/share/fish/vendor_functions.d`); process.stderr.write('[INFO] using fallback fish environment paths\n'); } export namespace AutoloadedPathVariables { /** * Type guard for autoloaded fish variables */ export function includes(name: string): name is AutoloadedFishVariableName { return autoloadedFishVariableNames.includes(name as AutoloadedFishVariableName); } /** * getter util for autoloaded fish variables, returns array of strings that * are separated by `:`, or empty array if variable is not set */ export function get(variable: AutoloadedFishVariableName): string[] { return env.getAsArray(variable); } /* * display fish variable in the format that would be shown using * ``` * set --show $variable * ``` */ export function asShowDocumentation(variable: AutoloadedFishVariableName): string { const value = get(variable); return [ `$${variable} set in global scope, unexported, with ${value.length} elements`, ...value.map((item, idx) => { return `$${variable}[${idx + 1}]: |${item}|`; }), ].join('\n'); } /** * Probably will not be used, but allows to directly append new values to autoloaded fish variables */ export function update(variable: AutoloadedFishVariableName, ...newValues: string[]): string { const values = get(variable); const updatedValues = [...values, ...newValues].join(':'); env.set(variable, updatedValues); return updatedValues; } /** * for debugging purposes, returns un-split value of autoloaded fish variable */ export function read(variable: AutoloadedFishVariableName): string { return env.get(variable) || ''; } /** * returns all autoloaded fish variables */ export function all(): AutoloadedFishVariableName[] { return Array.from(autoloadedFishVariableNames); } /** * finds autoloaded fish variable's values by its name */ export function find(key: string): string[] { if (includes(key)) { return get(key); } return []; } /** * alias for includes, without type guard */ export function has(key: string): boolean { return includes(key); } export function getHoverDocumentation(variable: string): string { if (includes(variable)) { const doc = PrebuiltDocumentationMap.getByType('variable').find(({ name }) => name === variable); let description = 'Autoloaded fish variable'; description += doc?.description ? [ '\n' + md.separator(), doc.description, ].join('\n') : ''; return [ `(${md.italic('variable')}) ${md.bold('$' + variable)}`, description, md.separator(), md.codeBlock('txt', asShowDocumentation(variable)), ].join('\n'); } return ''; } /** * Find an autoloaded function file by searching fish_function_path directories. * Returns the full path to the function file if found, or null if not found. * * @param functionName - The name of the function to find * @returns The absolute path to the function file, or null if not found */ export function findAutoloadedFunctionPath(functionName: string): string | null { // Get all function paths from fish_function_path const functionPaths = get('fish_function_path'); // Search each directory for the function file for (const dir of functionPaths) { const functionFilePath = join(dir, `${functionName}.fish`); if (existsSync(functionFilePath)) { return functionFilePath; } } return null; } } ================================================ FILE: src/utils/progress-notification.ts ================================================ import { connection } from './startup'; import { config } from '../config'; import { WorkDoneProgressReporter } from 'vscode-languageserver'; import { logger } from '../logger'; type ProgressAction = | { kind: 'begin'; title: string; percentage?: number; message?: string; cancellable?: boolean; timestamp: number; } | { kind: 'report'; percentage?: number; message?: string; timestamp: number; } | { kind: 'end'; timestamp: number; }; /** * Simplified progress notification wrapper that only shows progress * when the config allows it. Used for long-running operations like * workspace analysis. */ export class ProgressNotification implements WorkDoneProgressReporter { private token: string; private static instanceCounter = 0; private instanceId: number; private caller: string = 'unknown'; private isReady: boolean = false; private queue: ProgressAction[] = []; private constructor(token: string) { this.token = token; this.instanceId = ++ProgressNotification.instanceCounter; } public static isSupported(): boolean { return !!config.fish_lsp_show_client_popups; } /** * Create a progress notification if supported by config */ public static async create(caller?: string): Promise { const token = `fish-lsp-${caller || 'progress'}-${Date.now()}`; const progress = new ProgressNotification(token); progress.caller = caller || 'unknown'; const stack = new Error().stack?.split('\n')[2]?.trim() || 'unknown'; logger.debug(`[PROGRESS-${progress.instanceId}] CREATE from ${progress.caller} | ${stack}`); logger.debug(`SHOULD CREATE \`progress\` NOTIFICATION: ${ProgressNotification.isSupported()}`); if (ProgressNotification.isSupported()) { const startTime = performance.now(); try { await connection.sendRequest('window/workDoneProgress/create', { token }); const elapsed = performance.now() - startTime; progress.isReady = true; logger.debug(`[PROGRESS-${progress.instanceId}] CREATED \`progress\` NOTIFICATION with token: ${token} (took ${elapsed.toFixed(2)}ms)`); progress.flushQueue(); } catch (error) { const elapsed = performance.now() - startTime; logger.warning(`[PROGRESS-${progress.instanceId}] Failed to create progress reporter after ${elapsed.toFixed(2)}ms`, { error }); progress.queue = []; // Clear queue on error } } else { logger.debug(`[PROGRESS-${progress.instanceId}] SKIPPING CREATION OF \`progress\` NOTIFICATION`); } return progress; } private sendNotification(value: ProgressAction): void { connection.sendNotification('$/progress', { token: this.token, value, }); } private flushQueue(): void { if (!this.isReady || this.queue.length === 0) return; const now = performance.now(); logger.debug(`[PROGRESS-${this.instanceId}] Flushing ${this.queue.length} queued actions`); const actions = [...this.queue]; this.queue = []; for (const action of actions) { const delay = now - action.timestamp; if (delay > 10) { logger.debug(`[PROGRESS-${this.instanceId}] Action '${action.kind}' delayed by ${delay.toFixed(2)}ms`); } this.sendNotification(action); } } private enqueue(action: ProgressAction): void { if (!ProgressNotification.isSupported()) return; if (this.isReady) { this.sendNotification(action); } else { this.queue.push(action); } } public begin(title: string, percentage?: number, message?: string, cancellable?: boolean): void; public begin(title: string = '[fish-lsp] analysis', percentage?: number, message?: string, cancellable?: boolean): void { logger.info(`[PROGRESS-${this.instanceId}] BEGIN from ${this.caller}: "${title}" (${percentage}%, msg: "${message}")`); this.enqueue({ kind: 'begin', title, percentage, message, cancellable, timestamp: performance.now() }); } public report(percentage: number): void; public report(message: string): void; public report(percentage: number, message: string): void; public report(arg0: string | number, message?: string): void { logger.info(`[PROGRESS-${this.instanceId}] REPORT from ${this.caller}: ${JSON.stringify({ arg0, message })}`); const action: ProgressAction = { kind: 'report', timestamp: performance.now() }; if (typeof arg0 === 'number') { action.percentage = arg0; if (message) action.message = message; } else if (typeof arg0 === 'string') { action.message = arg0; } this.enqueue(action); } public done(): void { logger.info(`[PROGRESS-${this.instanceId}] DONE from ${this.caller}`); this.enqueue({ kind: 'end', timestamp: performance.now() }); } } ================================================ FILE: src/utils/semantics.ts ================================================ import { SemanticTokensLegend, Range, Position, } from 'vscode-languageserver'; import { SyntaxNode } from 'web-tree-sitter'; import { isBuiltin } from './builtins'; import { PrebuiltDocumentationMap } from './snippets'; import { analyzer } from '../analyze'; import { cachedCompletionMap } from '../server'; /** * Internal semantic token representation */ export interface SemanticToken { line: number; startChar: number; length: number; tokenType: number; tokenModifiers: number; } export namespace SemanticToken { export function create( line: number, startChar: number, length: number, tokenType: number, tokenModifiers: number | string[] = 0, ): SemanticToken { let mods = 0; if (Array.isArray(tokenModifiers)) { mods = calculateModifiersMask(...tokenModifiers); } else if (typeof tokenModifiers === 'number') { mods = tokenModifiers; } return { line, startChar, length, tokenType, tokenModifiers: mods, }; } export function fromNode( node: SyntaxNode, tokenType: number, tokenModifiers: number | string[] = 0, ) { return create( node.startPosition.row, node.startPosition.column, node.endIndex - node.startIndex, tokenType, tokenModifiers, ); } export function fromPosition( pos: { line: number; character: number; }, length: number, tokenType: number, tokenModifiers: number | string[] = 0, ) { return create( pos.line, pos.character, length, tokenType, tokenModifiers, ); } export function fromRange(params: { range: Range; tokenType: SemanticTokenType; tokenModifiers: number | string[]; }) { const range = params.range; const tokenType = getTokenTypeIndex(params.tokenType); const tokenModifiers = params.tokenModifiers; return create( range.start.line, range.start.character, range.end.line === range.start.line ? range.end.character - range.start.character : 0, tokenType, tokenModifiers, ); } } export const SemanticTokenTypes = { ['function']: 'function', // User-defined functions and fish-shipped functions ['variable']: 'variable', // Variables ['keyword']: 'keyword', // Built-in commands from `builtin -n` ['operator']: 'operator', // Operators like `--`, `;` ['decorator']: 'decorator', // Shebangs ['string']: 'string', // Strings (future use) ['number']: 'number', // Numbers (integers and floats) ['event']: 'event', // Events (future use) } as const; export type SemanticTokenType = (typeof SemanticTokenTypes)[keyof typeof SemanticTokenTypes]; export const SemanticTokenModifiers = { ['local']: 'local', // Local scope variables/functions ['inherit']: 'inherit', // Inherited variables ['function']: 'function', // Function modifier ['global']: 'global', // Global scope variables/functions ['universal']: 'universal', // Universal scope variables ['export']: 'export', // Exported variables ['defaultLibrary']: 'defaultLibrary', // Fish-shipped functions (now builtins and other shipped functions are both 'defaultLibrary') } as const; export type SemanticTokenModifier = (typeof SemanticTokenModifiers)[keyof typeof SemanticTokenModifiers]; export namespace FishSemanticTokens { export const types = Object.values(SemanticTokenTypes) .reduce((acc, value, index) => { acc[value as SemanticTokenType] = index; return acc; }, {} as Record); export const mods = Object.values(SemanticTokenModifiers) .reduce((acc, value, index) => { acc[value as SemanticTokenModifier] = index; return acc; }, {} as Record); export const legend: SemanticTokensLegend = { tokenTypes: Object.values(SemanticTokenTypes), tokenModifiers: Object.values(SemanticTokenModifiers), }; export function modMaskToStringArray(mask: number): string[] { const result: string[] = []; for (const [mod, index] of Object.entries(mods)) { if (mask & 1 << index) { result.push(mod); } } return result; } } export function getTokenTypeIndex(tokenType: string): number { return FishSemanticTokens.types[tokenType as SemanticTokenType] || 0; } export function getModifierIndex(modifier: string): number { return FishSemanticTokens.mods[modifier as SemanticTokenModifier] || 0; } export function calculateModifiersMask(...modifiers: string[]): number { let mask = 0; for (const modifier of modifiers) { const index = getModifierIndex(modifier); if (index !== -1) { mask |= 1 << index; } } return mask; } export function getModifiersFromMask(mask: number): string[] { const modifiers: string[] = []; for (let i = 0; i < Object.values(FishSemanticTokens.mods).length; i++) { const modifier = Object.keys(FishSemanticTokens.mods)[i]; if (modifier && mask & 1 << i) { modifiers.push(modifier); } } return modifiers; } export function nodeIntersectsRange(node: SyntaxNode, range: Range): boolean { const nodeStart = Position.create(node.startPosition.row, node.startPosition.column); const nodeEnd = Position.create(node.endPosition.row, node.endPosition.column); return !( nodeEnd.line < range.start.line || nodeEnd.line === range.start.line && nodeEnd.character < range.start.character || nodeStart.line > range.end.line || nodeStart.line === range.end.line && nodeStart.character > range.end.character ); } export function getPositionFromOffset(content: string, offset: number): { line: number; character: number; } { let line = 0; let character = 0; for (let i = 0; i < offset && i < content.length; i++) { if (content[i] === '\n') { line++; character = 0; } else { character++; } } return { line, character }; } export function getTokenTypePriority(tokenTypeIndex: number, modifiersMask: number = 0): number { const tokenTypesArray = FishSemanticTokens.legend.tokenTypes; const tokenType = tokenTypesArray[tokenTypeIndex]; if (!tokenType) { return 30; } const pathModifierIndex = FishSemanticTokens.legend.tokenModifiers.indexOf('path'); const filenameModifierIndex = FishSemanticTokens.legend.tokenModifiers.indexOf('filename'); const definitionModifierIndex = FishSemanticTokens.legend.tokenModifiers.indexOf('definition'); if (modifiersMask > 0) { if (tokenType === 'variable' && definitionModifierIndex !== -1 && modifiersMask & 1 << definitionModifierIndex) { return 130; } if (pathModifierIndex !== -1 && modifiersMask & 1 << pathModifierIndex) { return 120; } if (filenameModifierIndex !== -1 && modifiersMask & 1 << filenameModifierIndex) { return 115; } } const basePriorities: Record = { operator: 110, keyword: 105, decorator: 103, function: 100, method: 100, variable: 98, parameter: 95, property: 90, type: 80, class: 80, namespace: 80, event: 70, number: 50, comment: 40, string: 30, regexp: 10, }; return basePriorities[tokenType] || 30; } export function analyzeValueType(text: string): { tokenType: string; modifiers?: string[]; } { if (/^\d+$/.test(text)) { return { tokenType: 'number' }; } if (/^\d*\.\d+$/.test(text)) { return { tokenType: 'number' }; } if (/^\/[a-zA-Z0-9_\-\/\.]*/.test(text)) { const hasExtension = /\.[a-zA-Z0-9]+$/.test(text); if (hasExtension && !text.endsWith('/')) { return { tokenType: 'property', modifiers: ['filename'] }; } else { return { tokenType: 'property', modifiers: ['path'] }; } } if (/^~(\/[a-zA-Z0-9_\-\/\.]*)?$/.test(text)) { return { tokenType: 'property', modifiers: ['path'] }; } if (/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/.test(text)) { return { tokenType: 'property', modifiers: ['filename'] }; } if (/^https?:\/\//.test(text)) { return { tokenType: 'string' }; } if (/^(true|false)$/i.test(text)) { return { tokenType: 'keyword' }; } if (/^\$[A-Z_][A-Z0-9_]*$/i.test(text)) { return { tokenType: 'variable' }; } return { tokenType: 'string' }; } /** * Get semantic token modifiers for a command based on its definition * @param commandName - The name of the command * @returns Bitmask of token modifiers */ /** * Get semantic token modifiers for a variable based on its definition * @param variableName - The name of the variable (without $) * @param documentUri - Optional document URI to search for local symbols * @returns Bitmask of token modifiers */ export function getVariableModifiers(variableName: string, documentUri?: string): number { // Look up the variable in both local and global symbols let symbols = analyzer.globalSymbols.find(variableName); // If we have a document URI, also check local symbols if (documentUri && symbols.length === 0) { const localSymbols = analyzer.cache.getFlatDocumentSymbols(documentUri); const localMatches = localSymbols.filter(s => s.name === variableName && (s.fishKind === 'SET' || s.fishKind === 'READ' || s.fishKind === 'VARIABLE' || s.fishKind === 'FUNCTION_VARIABLE' || s.fishKind === 'EXPORT' || s.fishKind === 'FOR' || s.fishKind === 'ARGPARSE' || s.fishKind === 'INLINE_VARIABLE')); if (localMatches.length > 0) { symbols = localMatches; } } if (symbols.length === 0) { // No definition found return 0; } // Use the first symbol found (most relevant) const symbol = symbols[0]!; // Get modifiers based on the symbol's scope const modifiers: string[] = []; if (symbol.isGlobal()) { modifiers.push('global'); } else if (symbol.isLocal()) { modifiers.push('local'); } // Add export modifier if applicable if (symbol.fishKind === 'EXPORT' || symbol.fishKind === 'SET' || symbol.fishKind === 'FUNCTION_VARIABLE') { const options = symbol.options || []; for (const opt of options) { if (opt.isOption('-x', '--export')) { modifiers.push('export'); break; } } } return calculateModifiersMask(...modifiers); } /** * Information about a command's definition and modifiers */ export type CommandModifierInfo = { modifiers: number; isDefinedInDocument: boolean; }; /** * Get semantic token modifiers for a command and check if it's defined in the current document * @param commandNode - The command node * @param documentUri - Optional document URI to search for local symbols * @returns Object with modifiers bitmask and whether symbol is defined in this document */ export function getCommandModifierInfo(commandNode: SyntaxNode, documentUri?: string): CommandModifierInfo { const commandName = commandNode.firstNamedChild?.text; if (!commandName) { return { modifiers: 0, isDefinedInDocument: false }; } // Check if it's a builtin command if (isBuiltin(commandName)) { return { modifiers: calculateModifiersMask('defaultLibrary'), isDefinedInDocument: false }; } const allCommands = PrebuiltDocumentationMap.getByType('command'); if (allCommands.some(s => commandName === s.name)) { return { modifiers: calculateModifiersMask('global'), isDefinedInDocument: false }; } // Look up the command in both local and global symbols let symbols = analyzer.globalSymbols.find(commandName); let isDefinedInDocument = false; // If we have a document URI, also check local symbols if (documentUri) { const localSymbols = analyzer.cache.getFlatDocumentSymbols(documentUri); const localMatches = localSymbols.filter(s => s.name === commandName && (s.fishKind === 'FUNCTION' || s.fishKind === 'ALIAS'), ); if (localMatches.length > 0) { symbols = localMatches; isDefinedInDocument = true; } } const firstGlobal = cachedCompletionMap?.get('function')?.find(c => c.label === commandName); if (symbols.length === 0) { // No definition found - could be an external command or not found if (firstGlobal) { return { modifiers: calculateModifiersMask('global'), isDefinedInDocument: false }; } return { modifiers: 0, isDefinedInDocument: false }; } // Use the first symbol found (most relevant) const symbol = symbols[0]!; // Check if it's a function if (symbol.fishKind === 'FUNCTION') { const modifiers: string[] = []; // Check if it's autoloaded if (symbol.isGlobal() && symbol.document.isAutoloaded() && symbol.name === symbol.document.getAutoLoadName()) { modifiers.push('global', 'autoloaded'); } else if (symbol.isGlobal()) { // Global but not autoloaded modifiers.push('global', 'script'); } else if (symbol.isLocal()) { modifiers.push('local'); } return { modifiers: calculateModifiersMask(...modifiers), isDefinedInDocument }; } // Check if it's an alias if (symbol.fishKind === 'ALIAS') { const modifiers: string[] = []; if (symbol.document.isAutoloaded() && symbol.scope.scopeTag === 'global') { modifiers.push('global'); } modifiers.push('script'); return { modifiers: calculateModifiersMask(...modifiers), isDefinedInDocument }; } return { modifiers: 0, isDefinedInDocument }; } export function getCommandModifiers(commandNode: SyntaxNode, documentUri?: string): number { return getCommandModifierInfo(commandNode, documentUri).modifiers; } // ============================================================================ // Helper Functions // ============================================================================ export type TextMatchPosition = { startLine: number; startChar: number; endLine: number; endChar: number; matchLength: number; matchText: string; }; /** * Search for text within a SyntaxNode and return position information for matches * @param node - The SyntaxNode to search within * @param filter - String or RegExp to search for * @returns Array of TextMatchPosition objects for all matches */ export function getTextMatchPositions(node: SyntaxNode, filter: string | RegExp): TextMatchPosition[] { const matches: TextMatchPosition[] = []; const text = node.text; const nodeStartLine = node.startPosition.row; const nodeStartCol = node.startPosition.column; if (typeof filter === 'string') { // Simple string search let index = 0; while ((index = text.indexOf(filter, index)) !== -1) { const matchPosition = calculatePositionFromOffset( text, index, nodeStartLine, nodeStartCol, ); matches.push({ startLine: matchPosition.line, startChar: matchPosition.char, endLine: matchPosition.line, // Single line match for string search endChar: matchPosition.char + filter.length, matchLength: filter.length, matchText: filter, }); index += filter.length; } } else { // RegExp search const regex = new RegExp(filter.source, filter.flags.includes('g') ? filter.flags : filter.flags + 'g'); let match; while ((match = regex.exec(text)) !== null) { const matchPosition = calculatePositionFromOffset( text, match.index, nodeStartLine, nodeStartCol, ); const matchText = match[0]; const newlineCount = (matchText.match(/\n/g) || []).length; const endLine = matchPosition.line + newlineCount; let endChar: number; if (newlineCount > 0) { // Multi-line match - calculate end position from last line const lastLineStart = matchText.lastIndexOf('\n') + 1; endChar = matchText.length - lastLineStart; } else { // Single line match endChar = matchPosition.char + matchText.length; } matches.push({ startLine: matchPosition.line, startChar: matchPosition.char, endLine, endChar, matchLength: matchText.length, matchText, }); } } return matches; } /** * Calculate line and character position from text offset */ export function calculatePositionFromOffset( text: string, offset: number, baseLineNumber: number, baseColumnNumber: number, ): { line: number; char: number; } { const textUpToOffset = text.substring(0, offset); const lines = textUpToOffset.split('\n'); const lineOffset = lines.length - 1; if (lineOffset === 0) { // Same line as node start return { line: baseLineNumber, char: baseColumnNumber + offset, }; } else { // Different line - calculate from last newline return { line: baseLineNumber + lineOffset, char: lines[lines.length - 1]!.length, }; } } /** * Create SemanticTokens from TextMatchPosition results * @param matches - Array of TextMatchPosition results from getTextMatchPositions * @param tokenType - Token type index * @param modifiers - Token modifiers mask (default: 0) * @returns Array of SemanticTokens */ export function createTokensFromMatches( matches: TextMatchPosition[], tokenType: number, modifiers: number = 0, ): SemanticToken[] { return matches.map(match => SemanticToken.create( match.startLine, match.startChar, match.matchLength, tokenType, modifiers, ), ); } /** * Check if a node's position is already covered by existing tokens * @param node - The syntax node to check * @param tokens - Array of existing semantic tokens * @returns True if the node is covered by any existing token */ export function isNodeCoveredByTokens(node: SyntaxNode, tokens: SemanticToken[]): boolean { const nodeStart = { line: node.startPosition.row, char: node.startPosition.column }; const nodeEnd = { line: node.endPosition.row, char: node.endPosition.column }; for (const token of tokens) { const tokenEnd = token.startChar + token.length; // Check if the node overlaps with this token if (token.line === nodeStart.line) { // Same line - check character ranges if (token.startChar <= nodeStart.char && tokenEnd >= nodeEnd.char) { return true; // Node is completely covered by this token } if (token.startChar < nodeEnd.char && tokenEnd > nodeStart.char) { return true; // Partial overlap } } } return false; } ================================================ FILE: src/utils/snippets.ts ================================================ import helperCommandsJson from '../snippets/helperCommands.json'; import themeVariablesJson from '../snippets/syntaxHighlightingVariables.json'; import statusNumbersJson from '../snippets/statusNumbers.json'; import envVariablesJson from '../snippets/envVariables.json'; import localeVariablesJson from '../snippets/localeVariables.json'; import specialVariablesJson from '../snippets/specialFishVariables.json'; import pipeCharactersJson from '../snippets/pipesAndRedirects.json'; import fishlspEnvVariablesJson from '../snippets/fishlspEnvVariables.json'; import functionsJson from '../snippets/functions.json'; import { md } from './markdown-builder'; interface BaseJson { name: string; description: string; file?: string; // Optional: path to function definition file flags?: string[]; // Optional: function flags/options } type JsonType = 'command' | 'function' | 'pipe' | 'status' | 'variable'; type SpecialType = 'fishlsp' | 'env' | 'locale' | 'special' | 'theme'; type AllTypes = JsonType | SpecialType; export interface ExtendedBaseJson extends BaseJson { type: JsonType; specialType: SpecialType | undefined; } export namespace ExtendedBaseJson { export function create(o: BaseJson, type: JsonType, specialType?: SpecialType): ExtendedBaseJson { return { ...o, type, specialType, }; } export function is(o: any): o is ExtendedBaseJson { return o.type !== undefined && o.exactMatchOptions === undefined; } } type ValueType = boolean | boolean[] | number | number[] | string | string[]; export type CliObject = { name: string; valueType: ValueType; description: string; exactMatchOptions: boolean; type: string; options: string; defaultValue: string; }; export interface EnvVariableJson extends BaseJson { type: JsonType; specialType: SpecialType; shortDescription: string; valueType: 'boolean' | 'number' | 'string' | 'array'; isDeprecated: boolean; exactMatchOptions: boolean; options: string; defaultValue: string; } export namespace EnvVariableJson { export function create(o: BaseJson | any, exactMatchOptions: boolean, options: ValueType): EnvVariableJson { return { ...o, type: 'variable', specialType: 'fishlsp', isDeprecated: o.isDeprecated || false, exactMatchOptions, options, }; } export function is(o: any): o is EnvVariableJson { return o.type === 'variable' && o.specialType === 'fishlsp' && o.exactMatchOptions !== undefined; } const joinValueTypes = (valueType: ValueType = []): string => { if (!Array.isArray(valueType)) { return String.raw`${valueType}`; } return valueType.map(v => { if (Number.isInteger(v)) { return v; } return "'" + String.raw`${v}` + "'"; }).join(', '); }; const joinDefaultValue = (valueType: EnvVariableJson['valueType'], defaultValue: ValueType, optionValue: ValueType): string => { if (!Array.isArray(defaultValue)) { if (valueType === 'string' && defaultValue === '') { return `'${defaultValue}'`; } else if (valueType === 'number') { return `${defaultValue}`; } else if (valueType === 'boolean') { return `'${defaultValue}'`; } else if (valueType === 'array') { return '[\'\']'; } else { return ''; } } else { if (valueType === 'array' && defaultValue.length === 0) { if (Array.isArray(optionValue) && optionValue.some(v => Number.isInteger(v))) { return '[]'; } return '[]'; } else if (valueType === 'array' && defaultValue.length > 0) { return '[' + joinValueTypes(defaultValue) + ']'; } return joinValueTypes(defaultValue); } }; export function asCliObject(o: EnvVariableJson): CliObject { const options = joinValueTypes(o.options); const defaultValue = joinDefaultValue(o.valueType, o.defaultValue, o.options); return { name: o.name, valueType: o.valueType, description: o.description, exactMatchOptions: o.exactMatchOptions, type: o.type, options, defaultValue, }; } export function toCliOutput(o: EnvVariableJson, opts: CliToStringOpts = { includeType: true, includeOptions: true, includeDefaultValue: true, wrap: true, }): string { const cli = asCliObject(o); return fromCliOutputToString(cli, opts); } export function toMarkdownString(o: EnvVariableJson, opts: CliToStringOpts = { includeType: true, includeOptions: true, includeDefaultValue: true, wrap: true, }): string { const cli = asCliObject(o); return fromCliToMarkdownString(cli, opts); } } function buildBodySection(subtitle: string, body: string, shouldWrap: boolean = false, asMarkdown: boolean = false): string { const hasTitle = () => subtitle.length > 0; const titleStr = !hasTitle() ? '' : `(${subtitle}: `; const trailingBrace = !hasTitle() ? '' : ')'; const separator = asMarkdown ? '\n\n' : '\n'; if (!shouldWrap) return `${titleStr} ${body}${trailingBrace}`; const maxLineLength = 76; const output: string[] = []; let currentLine = titleStr; const leftpadBody = !hasTitle() ? '' : ' '.repeat(titleStr.length); // handle special case where body is empty or just quotes given for default section body = subtitle === 'Default' && ['""', "''", ''].includes(body.trim()) ? "''" : body; const splitBody = body.split(' '); const addComma = (idx: number) => idx === splitBody.length - 1 ? '' : ','; const words = asMarkdown ? body.split(' ').map((word, idx) => { const newWord = word !== "''" && !Number.isInteger(word) ? word.slice(0, -1) : word; if (Number.isInteger(newWord)) return md.inlineCode(newWord) + addComma(idx); if (newWord.startsWith("'") && newWord.endsWith("'")) { return md.inlineCode(newWord) + addComma(idx); } return md.inlineCode(word); }) : body.split(' '); for (const word of words) { if (currentLine.length + word.length + 1 > maxLineLength) { output.push(currentLine); currentLine = `${leftpadBody}${word} `; } else { currentLine += `${word} `; } } output.push(currentLine); return output.join(separator).trimEnd() + trailingBrace; } type CliToStringOpts = { includeType?: boolean; includeOptions?: boolean; includeDefaultValue?: boolean; wrap?: boolean; }; export function fromCliOutputToString(cli: CliObject, opts: CliToStringOpts = { includeType: true, includeOptions: true, includeDefaultValue: true, wrap: true, }): string { const title = opts?.includeType ? `$${cli.name} <${cli.valueType.toString().toUpperCase()}>` : cli.name; const body: string[] = []; body.push(...cli.description.split('\n\n')); if (opts.includeOptions) { if (cli.exactMatchOptions) { body.push(buildBodySection('Options', cli.options, opts.wrap)); } else { body.push(buildBodySection('Example Options', cli.options, opts.wrap)); } } if (opts.includeDefaultValue) body.push(buildBodySection('Default', cli.defaultValue, opts.wrap)); return [ title, ...body.join('\n').trimEnd().split('\n'), ].map(line => `# ${line}`).join('\n'); } export function fromCliToMarkdownString(cli: CliObject, opts: CliToStringOpts = { includeType: true, includeOptions: true, includeDefaultValue: true, wrap: true, }): string { const body: string[] = []; if (opts.includeOptions) { if (cli.exactMatchOptions) { body.push(buildBodySection(md.bold('Options'), cli.options, opts.wrap, true)); } else { body.push(buildBodySection(md.bold('Example Options'), cli.options, opts.wrap, true)); } } if (opts.includeDefaultValue) body.push(buildBodySection(md.bold('Default'), cli.defaultValue, opts.wrap, true)); return [ `(${md.bold(cli.type)}) ${md.inlineCode(cli.name)} <${cli.valueType.toString().toUpperCase()}>`, cli.description, md.separator(), ...body.join('\n\n').trimEnd().split('\n\n'), ].join('\n\n'); } export const fishLspObjs: EnvVariableJson[] = fishlspEnvVariablesJson.map((item: any | BaseJson | Partial) => EnvVariableJson.create(item, item?.exactMatchOptions, item?.options)); export type ExtendedJson = ExtendedBaseJson | EnvVariableJson; class DocumentationMap { private map: Map = new Map(); private typeMap: Map = new Map(); constructor(data: ExtendedJson[]) { data.forEach(item => { const curr = this.map.get(item.name) || []; // if (this.map.has(item.name)) return curr.push(item); this.map.set(item.name, curr); if (!this.typeMap.has(item.type)) this.typeMap.set(item.type, []); this.typeMap.get(item.type)!.push(item); }); } getByName(name: string): ExtendedJson[] { return name.startsWith('$') ? this.map.get(name.slice(1))?.filter(item => item.type === 'variable') || [] : this.map.get(name) || []; } getByType(type: JsonType, specialType?: SpecialType): ExtendedJson[] { const allOfType = this.typeMap.get(type) || []; return specialType !== undefined ? allOfType.filter(v => v?.specialType === specialType) : allOfType; } add(item: ExtendedBaseJson): void { const curr = this.map.get(item.name) || []; curr?.push(item); this.map.set(item.name, curr); if (!this.typeMap.has(item.type)) this.typeMap.set(item.type, []); this.typeMap.get(item.type)!.push(item); } findMatchingNames(query: string, ...types: AllTypes[]): ExtendedJson[] { const results: ExtendedBaseJson[] = []; this.map.forEach(items => { if (items.filter(item => item.name.startsWith(query) && (types.length === 0 || types.includes(item.type || item.specialType)))) { results.push(...items); } }); return results; } getSpecialVariableAsHoverDoc(name: `$${string}` | string): string { const variables = this.getByType('variable'); const searchStr = name.startsWith('$') ? name.slice(1) : name; const needle = searchStr === 'fish_lsp_logfile' ? 'fish_lsp_log_file' : searchStr; const result = variables.find(item => item.name === needle); if (!result) return ''; return [ `(${md.italic('variable')}) - ${md.inlineCode('$' + searchStr)}`, md.separator(), result.description, ].join('\n'); } // Additional helper methods can be added as needed } const allData: ExtendedBaseJson[] = [ ...helperCommandsJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'command')), ...pipeCharactersJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'pipe')), ...statusNumbersJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'status')), ...themeVariablesJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'variable', 'theme')), ...fishlspEnvVariablesJson.map((item: any | BaseJson | EnvVariableJson) => EnvVariableJson.create(item, item?.exactMatchOptions, item?.options)), ...envVariablesJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'variable', 'env')), ...localeVariablesJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'variable', 'locale')), ...specialVariablesJson.map((item: BaseJson) => ExtendedBaseJson.create(item, 'variable', 'special')), // Fish-shipped functions from functions.json (transform to BaseJson structure) // Preserve file and flags fields for browser/tooling use ...functionsJson.map((item: any) => ExtendedBaseJson.create({ name: item.name, description: item.description || `Fish function: ${item.name}`, file: item.file, flags: item.flags, }, 'function')), ]; export const PrebuiltDocumentationMap = new DocumentationMap(allData); export function getPrebuiltDocUrlByName(name: string): string { const objs = PrebuiltDocumentationMap.getByName(name); const res: string[] = []; objs.forEach((obj, _index) => { // const linkStr = objs.length > 1 ? new String(index + 1) : '' res.push(` - ${getPrebuiltDocUrl(obj)}`); }); return res.join('\n').trim(); } export function getPrebuiltDocUrl(obj: ExtendedBaseJson): string { switch (obj.type) { case 'command': return `https://fishshell.com/docs/current/cmds/${obj.name}.html`; case 'pipe': return 'https://fishshell.com/docs/current/language.html#input-output-redirection'; case 'status': return 'https://fishshell.com/docs/current/language.html#variables-status'; case 'variable': default: break; } // variable links switch (obj.specialType) { // case 'fishlsp' case 'env': return `https://fishshell.com/docs/current/language.html#envvar-${obj.name}`; case 'locale': return `https://fishshell.com/docs/current/language.html#locale-variables-${obj.name}`; case 'theme': // return 'https://fishshell.com/docs/current/interactive.html#variables-color' return `https://fishshell.com/docs/current/language.html#envvar-${obj.name}`; case 'special': return `https://fishshell.com/docs/current/language.html#envvar-${obj.name}`; // return 'https://fishshell.com/docs/current/language.html#special-variables' default: return ''; } } ================================================ FILE: src/utils/startup.ts ================================================ import * as path from 'path'; import * as os from 'os'; import * as net from 'net'; import { execSync } from 'child_process'; import FishServer from '../server'; import { createServerLogger, logger } from '../logger'; import { config, configHandlers } from '../config'; import { pathToUri, uriToReadablePath } from './translation'; import { PackageVersion } from './commander-cli-subcommands'; import { createConnection, InitializeParams, InitializeResult, StreamMessageReader, StreamMessageWriter, ProposedFeatures } from 'vscode-languageserver/node'; import * as Browser from 'vscode-languageserver/browser'; import { Connection } from 'vscode-languageserver'; // import { Workspace } from './workspace'; // import { workspaceManager } from './workspace-manager'; import { SyncFileHelper } from './file-operations'; // import { env } from './env-manager'; // Define proper types for the connection options export type ConnectionType = 'stdio' | 'node-ipc' | 'socket' | 'pipe'; export interface ConnectionOptions { port?: number; } export function createConnectionType(opts: { stdio?: boolean; nodeIpc?: boolean; pipe?: boolean; socket?: boolean; }): ConnectionType { if (opts.stdio) return 'stdio'; if (opts.nodeIpc) return 'node-ipc'; if (opts.pipe) return 'pipe'; if (opts.socket) return 'socket'; return 'stdio'; } /** * Global variable to hold the LSP connection. */ export let connection: Connection; /** * Used when the server is started via a shim, like in the vscode extension. * * Essentially, anywhere that is not using the cli directly to start the server, and * is instead using the module directly to connect to the server will need to set the connection * manually using this function. */ export function setExternalConnection(externalConnection: Connection): void { if (!connection) { logger.log('Setting external connection for FISH-LSP server'); connection = externalConnection; } } /** * Creates an LSP connection based on the specified type */ function createLspConnection(connectionType: ConnectionType = 'stdio', options: ConnectionOptions = {}) { let server: net.Server; switch (connectionType) { case 'node-ipc': connection = createConnection(ProposedFeatures.all); break; case 'pipe': case 'socket': if (!options.port) { logger.log('Socket connection requires a port number'); process.exit(1); } // For socket connections, we need to set up a TCP server server = net.createServer((socket) => { connection = createConnection( ProposedFeatures.all, new StreamMessageReader(socket), new StreamMessageWriter(socket), ); // Server setup code that would normally go in startServer setupServerWithConnection(connection); }); server.listen(options.port); logger.log(`Server listening on port ${options.port}`, server.address()); // For socket connections, we return null since the connection is created in the callback // This is a special case that needs to be handled in startServer break; case 'stdio': default: connection = createConnection( new StreamMessageReader(process.stdin), new StreamMessageWriter(process.stdout), ); break; } } /** * Creates a browser connection for the FISH-LSP server. */ export function createBrowserConnection(): Connection { let port = 8080; while (isPortTaken(port)) { port++; } connection = Browser.createConnection( new Browser.BrowserMessageReader(globalThis as any), new Browser.BrowserMessageWriter(globalThis as any), ); return connection; } import * as Net from 'net'; import chalk from 'chalk'; /** * Checks if a given port is currently in use. * @param port The port number to check. * @returns A Promise that resolves to `true` if the port is in use, `false` otherwise. * Rejects if an unexpected error occurs during the port check. */ function isPortTaken(port: number): Promise { return new Promise((resolve, reject) => { const tester = Net.createServer(); tester.once('error', (err: any) => { // If the error code is 'EADDRINUSE', the port is in use. if (err.code === 'EADDRINUSE') { resolve(true); } else { // Reject for other unexpected errors. reject(err); } }); tester.once('listening', () => { // If we successfully listen, the port is free. Close the server. tester.close(() => { resolve(false); }); }); tester.listen(port); }); } // Example usage: /** * Sets up the server with the provided connection */ function setupServerWithConnection(connection: Connection): void { connection.onInitialize( async (params: InitializeParams): Promise => { const { initializeResult } = await FishServer.create(connection, params); return initializeResult; }, ); // Start listening connection.listen(); // Setup logger createServerLogger(config.fish_lsp_log_file, connection.console); logger.log('Starting FISH-LSP server'); logger.log('Server started with the following handlers:', configHandlers); logger.log('Server started with the following config:', config); } /** * Starts the LSP server with the specified connection parameters */ export function startServer(connectionType: ConnectionType = 'stdio', options: ConnectionOptions = {}): void { // Create connection using the refactored function createLspConnection(connectionType, options); // For pipe and socket connections, the setup is handled in the connection creation if (connectionType === 'pipe' || connectionType === 'socket' || !connection) { // Connection is already set up in createLspConnection for pipe/socket connections return; } // For other connection types, set up the server with the connection setupServerWithConnection(connection); } export async function timeOperation( operation: () => Promise, label: string, ): Promise { const start = performance.now(); try { const result = await operation(); const end = performance.now(); const duration = end - start; logger.logToStdoutJoined( formatAlignedColumns([ chalk.blue(`${label}:`.padEnd(75)), `${chalk.white.bold(duration.toFixed(2))} ${chalk.white('ms')}`.padStart(10), ]), ); return result; } catch (error) { const end = performance.now(); const duration = end - start; logger.logToStderr(chalk.red(`${label} failed after ${duration.toFixed(2)}ms`)); throw error; } } function fixupStartPath(startPath: string | undefined): string | undefined { if (!startPath) return undefined; if (startPath === '.') { return process.cwd(); } const resultPath = SyncFileHelper.expandEnvVars(startPath); if (SyncFileHelper.isAbsolutePath(resultPath)) { return resultPath; } return path.resolve(resultPath); } type TimeServerOpts = { workspacePath: string; warning: boolean; timeOnly: boolean; showFiles: boolean; }; const defaultTimeServerOpts: Partial = { workspacePath: '', warning: true, timeOnly: false, showFiles: false, }; /** * Time the startup of the server. Use inside `fish-lsp info --time-startup`. * Easy testing can be done with: * >_ `nodemon --watch src/ --ext ts --exec 'fish-lsp info --time-startup'` */ export async function timeServerStartup( opts: Partial = defaultTimeServerOpts, ): Promise { // define a local server instance let server: FishServer | undefined; // fix the start path if a relative path is given const startPath = fixupStartPath(opts.workspacePath); // silence the logger for initial timing operations logger.setSilent(true); if (opts.warning && !opts.timeOnly) { // Title - centered logger.logToStdout(formatAlignedColumns([chalk.bold.blue('fish-lsp')])); logger.logToStdout(''); // Warning message with proper centering const warningLines = [ `${chalk.bold.underline.green('NOTE:')} a normal server instance will only start one of these workspaces`, '', 'if you frequently find yourself working inside a relatively large ', 'workspaces, please consider using the provided environment variable', '', `\`${chalk.bold.blue('set')} ${chalk.white('-gx')} ${chalk.cyan('fish_lsp_max_background_files')}\``, ]; warningLines.forEach((line) => { if (line === '') { // Empty line logger.logToStdout(''); } else { // Regular warning text - center each line logger.logToStdout(formatAlignedColumns([line])); } }); logger.logToStdout(''); } if (!opts.timeOnly) stdoutSeparator(); // 1. Time server creation and startup await timeOperation(async () => { // Create a null writable stream to discard JSON-RPC messages // This prevents them from polluting stdout during timing operations const { Writable } = await import('stream'); const nullStream = new Writable({ write(_chunk, _encoding, callback) { callback(); // Discard the data }, }); const connection = createConnection( new StreamMessageReader(process.stdin), new StreamMessageWriter(nullStream), ); // const startUri = path.join(os.homedir(), '.config', 'fish'); const startupParams: InitializeParams = { processId: process.pid, rootUri: startPath ? pathToUri(startPath) : pathToUri(path.join(os.homedir(), '.config', 'fish')), // rootPath: path.join(os.homedir(), '.config', 'fish'), clientInfo: { name: 'fish-lsp info --time-startup', version: PackageVersion, }, initializationOptions: { // fish_lsp_all_indexed_paths: startPath ? [startPath] : config.fish_lsp_all_indexed_paths, fish_lsp_max_background_files: config.fish_lsp_max_background_files, }, workspaceFolders: startPath ? [ { uri: pathToUri(startPath), name: startPath, }, ] : [ ...config.fish_lsp_all_indexed_paths.map(p => ({ uri: pathToUri(SyncFileHelper.expandEnvVars(p)), name: p.startsWith('$') ? p.slice(1) : path.basename(SyncFileHelper.expandEnvVars(p)), })), ], capabilities: { workspace: { workspaceFolders: true, }, }, }; ({ server } = await FishServer.create(connection, startupParams)); // Don't call connection.listen() - we're just timing, not handling LSP messages // This prevents JSON-RPC output from polluting stdout return server; }, 'Server Start Time'); let all: number = 0; const items: { [key: string]: string[]; } = {}; const counts: { [key: string]: number; } = {}; // 2. Time server initialization and background analysis // Call onInitialized() exactly as a real client would - this matches the real server flow 1:1 await timeOperation(async () => { if (!server) { throw new Error('Server not initialized'); } // Call onInitialized() which handles background analysis with proper flag management const initResult = await server.onInitialized({}); all = initResult.totalDocuments; /** Collect the stats from the initialization result */ for (const [path, uris] of Object.entries(initResult.items)) { let displayPath = uriToReadablePath(pathToUri(path)).replace(os.homedir(), '~'); displayPath = opts.workspacePath ? displayPath.replace(process.cwd().replace(os.homedir(), '~'), '$PWD') : displayPath; counts[displayPath] = uris.length; items[displayPath] = [...uris .map(u => uriToReadablePath(u)) .map(p => p.replace(os.homedir(), '~')) .map(p => opts.workspacePath ? p.replace(process.cwd().replace(os.homedir(), '~'), '$PWD') : p), ]; } }, 'Background Analysis Time'); // 3. Log the number of files indexed logger.logToStdoutJoined( formatAlignedColumns([ chalk.blue('Total Files Indexed: '), `${chalk.white.bold(all)} ${chalk.white('files')}`, ]), ); // 4. Stop here if we only want to log the time if (opts.timeOnly) return; stdoutSeparator(); // 5. Log the directories indexed if (!startPath) { const all_indexed = config.fish_lsp_all_indexed_paths; const leftMessage = chalk.blue('Indexed paths in ') + chalk.green('`$fish_lsp_all_indexed_paths`') + chalk.blue(':'); const amount = all_indexed.length; const itemsText = amount === 1 ? 'path' : 'paths'; const rightMessage = `${chalk.white.bold(amount)} ${chalk.white(itemsText)}`; logger.logToStdoutJoined( formatAlignedColumns([ leftMessage, rightMessage, ]), ); } else { const path = startPath.replace(process.cwd(), '.').replace(os.homedir(), '~'); const leftMessage = chalk.blue('Indexed paths in ') + chalk.green(`\`${path}\``) + chalk.blue(':'); const amount = Object.keys(items).length; const amountText = amount === 1 ? 'path' : 'paths'; const rightMessage = `${chalk.white.bold(amount)} ${chalk.white(amountText)}`; logger.logToStdoutJoined( formatAlignedColumns([ leftMessage, rightMessage, ]), ); } // 6. Log the items indexed Object.keys(items).forEach((item, idx) => { const text = item.length > 55 ? '...' + item.slice(item.length - 52) : item; const filesCount = items[item]?.length || 0; const result = formatAlignedColumns([ { text: `${idx + 1}`, padLeft: ' [', padRight: '] ', }, { text: chalk.green(text), padLeft: ' | `', padRight: '` | ', align: 'left', truncate: true, truncateIndicator: '…', truncateBehavior: 'left', }, chalk.white(`${chalk.white.bold(filesCount)} ${chalk.white(filesCount === 1 ? 'file' : 'files')}`), ]); logger.logToStdout(result); }); if (!opts.timeOnly) stdoutSeparator(); if (opts.showFiles) { Object.keys(items).forEach((item, idx) => { const paths = items[item]; if (!paths || paths?.length === 0) return; if (idx > 0) stdoutSeparator(); logger.logToStdoutJoined( formatAlignedColumns([ chalk.blue('Files in Folder'), chalk.green(`\`${item}\``), ]), ); paths.forEach((file, idx) => { const text = file.length > 55 ? file.slice(item.length - 55) : file; logger.logToStdoutJoined( formatAlignedColumns([ { text: chalk.blue(`${idx + 1}`), padLeft: ' [', padRight: '] ', truncate: true, truncateIndicator: ' ', truncateBehavior: 'right', }, { text: text, align: 'right', maxWidth: 55, truncate: true, truncateIndicator: '…', truncateBehavior: 'left', }, ]), ); }); }); } } export type AlignedItem = string | { text: string; align?: 'left' | 'center' | 'right'; // Truncation options truncate?: boolean; truncateIndicator?: string; truncateBehavior?: 'left' | 'right' | 'middle'; maxWidth?: number; // Padding options (applied after truncation, before alignment) // Note: padLeft/padRight cannot be used with pad pad?: string; padLeft?: string; padRight?: string; // Text transformation transform?: 'uppercase' | 'lowercase' | 'capitalize'; // Width constraints minWidth?: number; fixedWidth?: number; }; // Helper function to process individual items with all formatting options function processAlignedItem(item: AlignedItem, availableWidth: number, defaultAlign: 'left' | 'center' | 'right'): { text: string; cleanLength: number; align: 'left' | 'center' | 'right'; } { if (typeof item === 'string') { return { text: item, cleanLength: item.replace(/\x1b\[[0-9;]*m/g, '').length, align: defaultAlign }; } let processedText = item.text; // Apply text transformation if (item.transform) { const cleanText = processedText.replace(/\x1b\[[0-9;]*m/g, ''); const ansiMatches = processedText.match(/\x1b\[[0-9;]*m/g) || []; let transformedClean = cleanText; switch (item.transform) { case 'uppercase': transformedClean = cleanText.toUpperCase(); break; case 'lowercase': transformedClean = cleanText.toLowerCase(); break; case 'capitalize': transformedClean = cleanText.charAt(0).toUpperCase() + cleanText.slice(1).toLowerCase(); break; } // Reinsert ANSI codes (simplified approach) processedText = transformedClean; ansiMatches.forEach((ansi, i) => { if (i < transformedClean.length) { processedText = processedText.slice(0, i) + ansi + processedText.slice(i); } else { processedText += ansi; } }); } // Calculate padding lengths let padLeftLen = 0; let padRightLen = 0; let padLeftText = ''; let padRightText = ''; if (item.pad) { padLeftLen = padRightLen = item.pad.length; padLeftText = padRightText = item.pad; } else { if (item.padLeft) { padLeftLen = item.padLeft.length; padLeftText = item.padLeft; } if (item.padRight) { padRightLen = item.padRight.length; padRightText = item.padRight; } } // Determine alignment direction for truncation const align = item.align || defaultAlign; const targetWidth = item.maxWidth || availableWidth; // Account for padding in target width const totalPaddingLength = padLeftLen + padRightLen; const availableTextWidth = targetWidth - totalPaddingLength; // Handle truncation if needed if (item.truncate !== false && availableTextWidth > 0) { // default to true if maxWidth is set const cleanText = processedText.replace(/\x1b\[[0-9;]*m/g, ''); if (cleanText.length > availableTextWidth) { const indicator = item.truncateIndicator || '…'; const indicatorLen = indicator.length; const maxContentLength = availableTextWidth - indicatorLen; if (maxContentLength <= 0) { processedText = indicator; } else { let truncatedText = ''; // Determine truncation direction: use explicit truncateBehavior if provided, otherwise use alignment const truncationDirection = item.truncateBehavior || (align === 'right' ? 'left' : align === 'center' ? 'middle' : 'right'); if (truncationDirection === 'left') { // Truncate from left (remove from beginning) truncatedText = indicator + cleanText.slice(cleanText.length - maxContentLength); } else if (truncationDirection === 'middle') { // Truncate from both sides (middle) const leftPortion = Math.floor(maxContentLength / 2); const rightPortion = maxContentLength - leftPortion; if (maxContentLength < cleanText.length) { truncatedText = cleanText.slice(0, leftPortion) + indicator + cleanText.slice(cleanText.length - rightPortion); } else { truncatedText = cleanText; } } else { // Truncate from right (remove from end - default) truncatedText = cleanText.slice(0, maxContentLength) + indicator; } processedText = truncatedText; } } } // Apply padding after truncation const finalText = padLeftText + processedText + padRightText; // Handle width constraints if (item.fixedWidth) { const cleanLength = finalText.replace(/\x1b\[[0-9;]*m/g, '').length; if (cleanLength < item.fixedWidth) { const padding = item.fixedWidth - cleanLength; if (align === 'center') { const leftPad = Math.floor(padding / 2); const rightPad = padding - leftPad; return { text: ' '.repeat(leftPad) + finalText + ' '.repeat(rightPad), cleanLength: item.fixedWidth, align }; } else if (align === 'right') { return { text: ' '.repeat(padding) + finalText, cleanLength: item.fixedWidth, align }; } else { return { text: finalText + ' '.repeat(padding), cleanLength: item.fixedWidth, align }; } } } if (item.minWidth) { const cleanLength = finalText.replace(/\x1b\[[0-9;]*m/g, '').length; if (cleanLength < item.minWidth) { const padding = item.minWidth - cleanLength; if (align === 'center') { const leftPad = Math.floor(padding / 2); const rightPad = padding - leftPad; return { text: ' '.repeat(leftPad) + finalText + ' '.repeat(rightPad), cleanLength: item.minWidth, align }; } else if (align === 'right') { return { text: ' '.repeat(padding) + finalText, cleanLength: item.minWidth, align }; } else { return { text: finalText + ' '.repeat(padding), cleanLength: item.minWidth, align }; } } } return { text: finalText, cleanLength: finalText.replace(/\x1b\[[0-9;]*m/g, '').length, align, }; } export function maxWidthForOutput(): number { function getColumnsFromEnv(): number | undefined { // Try multiple methods to get terminal width // 1. Check if COLUMNS is in environment if (process.env.COLUMNS) { const cols = parseInt(process.env.COLUMNS, 10); if (!isNaN(cols) && cols > 0) { return cols; } } // 2. Try using process.stdout.columns if available (Node.js TTY) if (process.stdout.columns && typeof process.stdout.columns === 'number') { return process.stdout.columns; } // 3. Try executing shell command to get COLUMNS (as fallback) try { // Try to get COLUMNS from shell environment const result = execSync('echo $COLUMNS', { encoding: 'utf8', timeout: 1000, stdio: ['pipe', 'pipe', 'ignore'], }).trim(); const cols = parseInt(result, 10); if (!isNaN(cols) && cols > 0) { return cols; } } catch { // Ignore errors from shell command } // 4. Default fallback return 95; } return Math.min(95, getColumnsFromEnv() || 95); // Ensure at least 95 characters wide } /** * Creates a string with aligned columns based on the number of input strings or explicit alignment * @param items The items to align - either strings (with default alignment) or objects with explicit alignment * @param maxWidth The maximum width of the output (defaults to process.env.COLUMNS or 95) * @returns A formatted string with properly aligned columns */ export function formatAlignedColumns(items: AlignedItem[], maxWidth?: number): string { const width = maxWidth || maxWidthForOutput(); if (items.length === 0) return ''; // Determine default alignment for each position const getDefaultAlign = (index: number, total: number): 'left' | 'center' | 'right' => { if (total === 1) return 'center'; if (total === 2) return index === 0 ? 'left' : 'right'; if (total === 3) return index === 0 ? 'left' : index === 1 ? 'center' : 'right'; return index === 0 ? 'left' : index === total - 1 ? 'right' : 'center'; }; // Process all items with their formatting options const processedItems = items.map((item, index) => { const defaultAlign = getDefaultAlign(index, items.length); return processAlignedItem(item, width, defaultAlign); }); // Calculate total content length const totalContentLength = processedItems.reduce((sum, item) => sum + item.cleanLength, 0); const availableSpace = Math.max(0, width - totalContentLength); if (availableSpace === 0) { return processedItems.map(item => item.text).join(''); } // Separate items by alignment const leftItems = processedItems.filter(item => item.align === 'left'); const centerItems = processedItems.filter(item => item.align === 'center'); const rightItems = processedItems.filter(item => item.align === 'right'); // Special case: only center items (single item should be centered) if (leftItems.length === 0 && rightItems.length === 0 && centerItems.length === 1) { const leftPadding = Math.max(0, Math.floor(availableSpace / 2)); const rightPadding = Math.max(0, availableSpace - leftPadding); return ' '.repeat(leftPadding) + centerItems[0]?.text + ' '.repeat(rightPadding); } // Build the result string let result = ''; // Add left-aligned items leftItems.forEach(item => { result += item.text; }); // Calculate remaining space after left and right items const leftLength = leftItems.reduce((sum, item) => sum + item.cleanLength, 0); const rightLength = rightItems.reduce((sum, item) => sum + item.cleanLength, 0); const centerLength = centerItems.reduce((sum, item) => sum + item.cleanLength, 0); const remainingSpace = width - leftLength - rightLength - centerLength; if (centerItems.length === 0) { // Only left and right items result += ' '.repeat(Math.max(0, remainingSpace)); } else { // Distribute remaining space around center items const numGaps = (leftItems.length > 0 ? 1 : 0) + Math.max(0, centerItems.length - 1) + (rightItems.length > 0 ? 1 : 0); const gapSize = numGaps > 0 ? Math.max(1, Math.floor(remainingSpace / numGaps)) : Math.floor(remainingSpace / 2); const extraSpace = remainingSpace - gapSize * numGaps; // Add gap before center items if there are left items if (leftItems.length > 0) { result += ' '.repeat(gapSize + (extraSpace > 0 ? 1 : 0)); } else if (centerItems.length > 0 && rightItems.length > 0) { result += ' '.repeat(gapSize); } // Add center items with gaps between them centerItems.forEach((item, index) => { result += item.text; if (index < centerItems.length - 1) { result += ' '.repeat(gapSize); } }); // Add gap after center items if there are right items if (rightItems.length > 0) { const usedExtraSpace = leftItems.length > 0 && extraSpace > 0 ? 1 : 0; const finalGapSize = gapSize + (extraSpace - usedExtraSpace > 0 ? 1 : 0); result += ' '.repeat(Math.max(1, finalGapSize)); } } // Add right-aligned items rightItems.forEach(item => { result += item.text; }); return result; } export function stdoutSeparator(): void { // Print a separator line to stdout logger.logToStdout(formatAlignedColumns([chalk.bold.white('-'.repeat(maxWidthForOutput()))])); } ================================================ FILE: src/utils/symbol-documentation-builder.ts ================================================ import os from 'os'; import { SymbolKind } from 'vscode-languageserver'; import { SyntaxNode } from 'web-tree-sitter'; import { isFunctionDefinitionName, isVariableDefinition, isProgram, isVariableDefinitionName } from './node-types'; //import { FishFlagOption, optionTagProvider } from './options'; import { symbolKindToString, uriToPath } from './translation'; import { MarkdownBuilder, md } from './markdown-builder'; import { PrebuiltDocumentationMap } from './snippets'; /** * Current CHANGELOG for documentation: * • functions with preceding spaces between their comments keep whitespace between * the comments and the function definition * • @see zoom_out.fish and yarn_reset.fish * - ~/.config/fish/functions/yarn_reset.fish (shows whole program) */ export class DocumentationStringBuilder { constructor( private name: string = name, private uri: string = uri, private kind: SymbolKind = kind, private inner: SyntaxNode = inner, //private outer = inner.parent || inner.previousSibling || null, ) {} private get outer() { if (isFunctionDefinitionName(this.inner) || isVariableDefinitionName(this.inner)) { return this.inner.parent; } return this.inner.previousSibling || null; } /** * ~/.config/fish/functions/yarn_reset.fish * causes error, shows entire file instead of just function * meaning that the outer node is being used when it shouldn't be */ private get precedingComments(): string { if (this.outer && isProgram(this.outer)) { return getPrecedingCommentString(this.inner); } if ( hasPrecedingFunctionDefinition(this.inner) && isVariableDefinition(this.inner) ) { return this.outer?.firstNamedChild?.text + ' ' + this.inner.text; } return getPrecedingCommentString(this.outer || this.inner); } get text(): string { const text = this.precedingComments; const lines = text.split('\n'); if (lines.length > 1 && this.outer) { const lastLine = this.outer.lastChild?.startPosition.column || 0; return lines .map((line) => line.replace(' '.repeat(lastLine), '')) .join('\n') .trimEnd(); } return text; } get shortenedUri(): string { const uriPath = uriToPath(this.uri)!; return uriPath.replace(os.homedir(), '~'); } // add this.tagString once further implemented toString() { const symbolString = symbolKindToString(this.kind); const prebuiltType = symbolString === 'function' ? 'command' : 'variable'; const prebuiltMatch = PrebuiltDocumentationMap.getByType(prebuiltType) .find(({ name }) => name === this.name); const info = prebuiltMatch?.description ? [ `defined in file: ${this.shortenedUri}`, md.separator(), prebuiltMatch.description, ].join('\n') : `defined in file: ${this.shortenedUri}`; return new MarkdownBuilder() .fromMarkdown( [ `(${md.italic(symbolString)})`, md.bold(this.name)], info, md.separator(), md.codeBlock('fish', this.text), ) .toString(); } } export namespace DocumentSymbolDetail { export function create(name: string, uri: string, kind: SymbolKind, inner: SyntaxNode, _outer: SyntaxNode | null = inner.parent || inner.previousSibling || null): string { return new DocumentationStringBuilder(name, uri, kind, inner).toString(); } } function getPrecedingCommentString(node: SyntaxNode): string { const comments: string[] = [node.text]; let current: SyntaxNode | null = node.previousNamedSibling; while (current && current.type === 'comment') { comments.unshift(current.text); current = current.previousSibling; } return comments.join('\n'); } function hasPrecedingFunctionDefinition(node: SyntaxNode): boolean { let current: SyntaxNode | null = node.previousSibling; while (current) { if (isFunctionDefinitionName(current)) { return true; } current = current.previousSibling; } return false; } ================================================ FILE: src/utils/translation.ts ================================================ import { DocumentSymbol, DocumentUri, SelectionRange, SymbolInformation, SymbolKind, TextDocumentItem } from 'vscode-languageserver'; import * as LSP from 'vscode-languageserver'; import { SyntaxNode } from 'web-tree-sitter'; import { URI } from 'vscode-uri'; import { findParentVariableDefinitionKeyword, isCommand, isCommandName, isFunctionDefinition, isFunctionDefinitionName, isProgram, isStatement, isString, isTopLevelDefinition, isTopLevelFunctionDefinition, isVariable } from './node-types'; import { LspDocument, Documents } from '../document'; import { getPrecedingComments, getRange } from './tree-sitter'; import * as LocationNamespace from './locations'; import * as os from 'os'; import { isBuiltin } from './builtins'; import { env } from './env-manager'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { WorkspaceUri } from './workspace'; const RE_PATHSEP_WINDOWS = /\\/g; export function isUri(stringOrUri: unknown): stringOrUri is DocumentUri { if (typeof stringOrUri !== 'string') { return false; } const uri = URI.parse(stringOrUri); return URI.isUri(uri); } /** a string that is a path to a file, not a uri */ export type PathLike = string; export function isPath(pathOrUri: unknown): pathOrUri is PathLike { return typeof pathOrUri === 'string' && !isUri(pathOrUri); } /** * Type guard to check if an object is a TextDocument from vscode-languageserver-textdocument * * @param value The value to check * @returns True if the value is a TextDocument, false otherwise */ export function isTextDocument(value: unknown): value is TextDocument { return ( typeof value === 'object' && value !== null && // TextDocument has these properties typeof (value as TextDocument).uri === 'string' && typeof (value as TextDocument).languageId === 'string' && typeof (value as TextDocument).version === 'number' && typeof (value as TextDocument).lineCount === 'number' && // TextDocument has these methods typeof (value as TextDocument).getText === 'function' && typeof (value as TextDocument).positionAt === 'function' && typeof (value as TextDocument).offsetAt === 'function' && // TextDocumentItem has direct 'text' property, TextDocument doesn't (value as any).text === undefined ); } /** * Type guard to check if an object is a TextDocumentItem from vscode-languageserver * * @param value The value to check * @returns True if the value is a TextDocumentItem, false otherwise */ export function isTextDocumentItem(value: unknown): value is TextDocumentItem { return ( typeof value === 'object' && value !== null && // TextDocumentItem has these properties typeof (value as TextDocumentItem).uri === 'string' && typeof (value as TextDocumentItem).languageId === 'string' && typeof (value as TextDocumentItem).version === 'number' && typeof (value as TextDocumentItem).text === 'string' && // TextDocument has these methods, TextDocumentItem doesn't (value as any).getText === undefined && (value as any).positionAt === undefined && (value as any).offsetAt === undefined && (value as any).lineCount === undefined ); } export function uriToPath(stringUri: DocumentUri): PathLike { const uri = URI.parse(stringUri); return normalizeFsPath(uri.fsPath); } export function pathToUri(filepath: PathLike, documents?: Documents | undefined): DocumentUri { // Yarn v2+ hooks tsserver and sends `zipfile:` URIs for Vim. Keep as-is. // Example: zipfile:///foo/bar/baz.zip::path/to/module if (filepath.startsWith('zipfile:')) { return filepath; } const fileUri = URI.file(filepath); const normalizedFilepath = normalizePath(fileUri.fsPath); const document = documents && documents.get(URI.file(normalizedFilepath).toString()); return document ? document.uri : fileUri.toString(); } /** * Normalizes the file system path. * * On systems other than Windows it should be an no-op. * * On Windows, an input path in a format like "C:/path/file.ts" * will be normalized to "c:/path/file.ts". */ export function normalizePath(filePath: PathLike): PathLike { const fsPath = URI.file(filePath).fsPath; return normalizeFsPath(fsPath); } /** * Normalizes the path obtained through the "fsPath" property of the URI module. */ export function normalizeFsPath(fsPath: string): string { return fsPath.replace(RE_PATHSEP_WINDOWS, '/'); } export function pathToRelativeFunctionName(filepath: PathLike): string { const relativeName = filepath.split('/').at(-1) || filepath; return relativeName.replace('.fish', ''); } export function uriInUserFunctions(uri: DocumentUri) { const path = uriToPath(uri); return path?.startsWith(`${os.homedir}/.config/fish`) || false; } export function nodeToSymbolInformation(node: SyntaxNode, uri: string): SymbolInformation { let name = node.text; const kind = toSymbolKind(node); const range = getRange(node); switch (kind) { case SymbolKind.Namespace: name = pathToRelativeFunctionName(uri); break; case SymbolKind.Function: case SymbolKind.Variable: case SymbolKind.File: case SymbolKind.Class: case SymbolKind.Null: default: break; } return SymbolInformation.create(name, kind, range, uri); } export function nodeToDocumentSymbol(node: SyntaxNode): DocumentSymbol { const name = node.text; let detail = node.text; const kind = toSymbolKind(node); let range = getRange(node); const selectionRange = getRange(node); const children: DocumentSymbol[] = []; let parent = node.parent || node; switch (kind) { case SymbolKind.Variable: parent = findParentVariableDefinitionKeyword(node) || node; detail = getPrecedingComments(parent); range = getRange(parent); break; case SymbolKind.Function: detail = getPrecedingComments(parent); range = getRange(parent); break; case SymbolKind.File: case SymbolKind.Class: case SymbolKind.Namespace: case SymbolKind.Null: default: break; } return DocumentSymbol.create(name, detail, kind, range, selectionRange, children); } export function createRange(startLine: number, startCharacter: number, endLine: number, endCharacter: number): LSP.Range { return { start: { line: startLine, character: startCharacter, }, end: { line: endLine, character: endCharacter, }, }; } export function toSelectionRange(range: SelectionRange): SelectionRange { const span = LocationNamespace.Range.toTextSpan(range.range); return SelectionRange.create( LocationNamespace.Range.fromTextSpan(span), range.parent ? toSelectionRange(range.parent) : undefined, ); } export function toLspDocument(filename: string, content: string): LspDocument { const doc = TextDocumentItem.create(pathToUri(filename), 'fish', 0, content); return new LspDocument(doc); } export function toSymbolKind(node: SyntaxNode): SymbolKind { if (isVariable(node)) { return SymbolKind.Variable; } else if (isFunctionDefinitionName(node)) { // change from isFunctionDefinition(node) return SymbolKind.Function; } else if (isString(node)) { return SymbolKind.String; } else if (isProgram(node) || isFunctionDefinition(node) || isStatement(node)) { return SymbolKind.Namespace; } else if (isBuiltin(node.text) || isCommandName(node) || isCommand(node)) { return SymbolKind.Class; } return SymbolKind.Null; } /** * Pretty much just for logging a symbol kind */ export function symbolKindToString(kind: SymbolKind) { switch (kind) { case SymbolKind.Variable: return 'variable'; case SymbolKind.Function: return 'function'; case SymbolKind.String: return 'string'; case SymbolKind.Namespace: return 'namespace'; case SymbolKind.Class: return 'class'; case SymbolKind.Null: return 'null'; default: return 'other'; } } /** * Converts a URI to a more readable path by replacing known prefixes with fish variables * or ~ for home directory. * * @param uri The URI to convert to a readable path * @returns A more readable path using fish variables or tilde when possible */ export function uriToReadablePath(uri: DocumentUri | WorkspaceUri): string { // First convert URI to filesystem path const path = uriToPath(uri); // Try to replace with fish variables first const autoloadedKeys = env.getAutoloadedKeys(); for (const key of autoloadedKeys) { const values = env.getAsArray(key); for (const value of values) { if (path.startsWith(value)) { return path.replace(value, `$${key}`); } } } // If no fish variables match, try to replace home directory with tilde const homeDir = os.homedir(); if (path.startsWith(homeDir)) { return path.replace(homeDir, '~'); } // Return the original path if no substitutions were made return path; } /** * @param node - SyntaxNode toSymbolKind/symbolKindToString wrapper for both * `string` and `number` type * @returns { * kindType: toSymbolKind(node) -> 13 | 12 | 15 | 3 | 5 | 21 * kindString: symbolKindToString(kindType) -> number * } */ export function symbolKindsFromNode(node: SyntaxNode): { kindType: SymbolKind; kindString: string; } { const kindType = toSymbolKind(node); const kindString = symbolKindToString(kindType); return { kindType, kindString, }; } export type AutoloadType = 'conf.d' | 'functions' | 'completions' | 'config' | ''; export type AutoloadFunctionCallback = (n: SyntaxNode) => boolean; /** * Closure for checking if a documents `node.type === function_definition` is * autoloaded. Callback checks the `document.uri` for determining which * autoloaded type to check for. * ___ * @param document - LspDocument to check if it is autoloaded * @returns (n: SyntaxNode) => boolean - true if the document is autoloaded */ export function isAutoloadedUriLoadsFunction(document: LspDocument): (n: SyntaxNode) => boolean { const callbackmap: Record boolean> = { 'conf.d': (node: SyntaxNode) => isTopLevelFunctionDefinition(node) && isFunctionDefinition(node), config: (node: SyntaxNode) => isTopLevelFunctionDefinition(node) && isFunctionDefinition(node), functions: (node: SyntaxNode) => { if (isTopLevelFunctionDefinition(node) && isFunctionDefinition(node)) { return node.firstChild?.text === document.getAutoLoadName(); } return false; }, completions: (_: SyntaxNode) => false, '': (_: SyntaxNode) => false, }; return callbackmap[document.getAutoloadType()]; } /** * The nodes that are considered autoloaded functions are the firstNamedChild of * a `function_definition` node. This is because the firstNamedChild is the * function's name (skipping the `function` keyword). * ___ * Closure for checking if a documents `node.parent.type === function_definition` * is autoloaded. Callback checks the `document.uri` for determining which * autoloaded type to check for. * ___ * @param document - LspDocument to check if it is autoloaded * @returns (n: SyntaxNode) => boolean - true if function name is autoloaded in the document */ export function isAutoloadedUriLoadsFunctionName(document: LspDocument): (n: SyntaxNode) => boolean { const callbackmap: Record boolean> = { 'conf.d': (node: SyntaxNode) => isTopLevelFunctionDefinition(node) && isFunctionDefinitionName(node), config: (node: SyntaxNode) => isTopLevelFunctionDefinition(node) && isFunctionDefinitionName(node), functions: (node: SyntaxNode) => { if (isTopLevelFunctionDefinition(node) && isFunctionDefinitionName(node)) { return node?.text === document.getAutoLoadName(); } return false; }, completions: (_: SyntaxNode) => false, '': (_: SyntaxNode) => false, }; return callbackmap[document.getAutoloadType()]; } export function isAutoloadedUriLoadsAliasName(document: LspDocument): (n: SyntaxNode) => boolean { const callbackmap: Record boolean> = { 'conf.d': (node: SyntaxNode) => isTopLevelDefinition(node), config: (node: SyntaxNode) => isTopLevelDefinition(node), functions: (_: SyntaxNode) => false, completions: (_: SyntaxNode) => false, '': (_: SyntaxNode) => false, }; return callbackmap[document.getAutoloadType()]; } export function shouldHaveAutoloadedFunction(document: LspDocument): boolean { return 'functions' === document.getAutoloadType(); } export function formatTextWithIndents(doc: LspDocument, line: number, text: string) { const indent = doc.getIndentAtLine(line); return text .split('\n') .map(line => indent + line) .join('\n'); } ================================================ FILE: src/utils/tree-sitter.ts ================================================ import { extname } from 'path'; import { Position, Range, URI } from 'vscode-languageserver'; import { Point, SyntaxNode, Tree } from 'web-tree-sitter'; import { findSetDefinedVariable, isFunctionDefinition, isVariableDefinition, isFunctionDefinitionName, isVariable, isScope, isProgram, isCommandName, isForLoop, findForLoopVariable } from './node-types'; import { Maybe } from './maybe'; // You can add this as a utility function or extend it if needed export function isSyntaxNode(obj: unknown): obj is SyntaxNode { return typeof obj === 'object' && obj !== null && 'id' in obj && 'type' in obj && 'text' in obj && 'tree' in obj && 'startPosition' in obj && 'endPosition' in obj && 'children' in obj && 'equals' in obj && 'isNamed' in obj && 'isMissing' in obj && 'isError' in obj && 'isExtra' in obj && typeof (obj as any).id === 'number' && typeof (obj as any).isNamed === 'boolean' && typeof (obj as any).isMissing === 'boolean' && typeof (obj as any).isError === 'boolean' && typeof (obj as any).isExtra === 'boolean' && typeof (obj as any).type === 'string' && typeof (obj as any).text === 'string' && typeof (obj as any).equals === 'function' && Array.isArray((obj as any).children); } /** * Returns an array for all the nodes in the tree (@see also nodesGen) * * @param {SyntaxNode} root - the root node to search from * @returns {SyntaxNode[]} all children of the root node (flattened) */ export function getChildNodes(root: SyntaxNode): SyntaxNode[] { const queue: SyntaxNode[] = [root]; const result: SyntaxNode[] = []; while (queue.length) { const current: SyntaxNode | undefined = queue.shift(); if (current) { result.push(current); } if (current && current.children) { queue.unshift(...current.children); } } return result; } export function getNamedChildNodes(root: SyntaxNode): SyntaxNode[] { const queue: SyntaxNode[] = [root]; const result: SyntaxNode[] = []; while (queue.length) { const current: SyntaxNode | undefined = queue.shift(); if (current && current.isNamed) { result.push(current); } if (current && current.children) { queue.unshift(...current.children); } } return result; } export function findChildNodes(root: SyntaxNode, predicate: (node: SyntaxNode) => boolean): SyntaxNode[] { const queue: SyntaxNode[] = [root]; const result: SyntaxNode[] = []; while (queue.length) { const current: SyntaxNode | undefined = queue.shift(); if (current && predicate(current)) { result.push(current); } if (current && current.children) { queue.unshift(...current.children); } } return result; } /** * Collect all nodes of specific types using breadth-first iteration * @param root - The root node to search from * @param types - Array of node types to collect * @returns Array of nodes matching the specified types */ export function collectNodesByTypes(root: SyntaxNode, types: string[]): SyntaxNode[] { const results: SyntaxNode[] = []; const queue: SyntaxNode[] = [root]; while (queue.length > 0) { const current = queue.shift()!; if (types.includes(current.type)) { results.push(current); } queue.push(...current.namedChildren); } return results; } /** * Gets path to root starting where index 0 is child node passed in. * Format: [child, child.parent, ..., root] * * @param {SyntaxNode} child - the lowest child of root * @returns {SyntaxNode[]} an array of ancestors to the descendent node passed in. */ export function getParentNodes(child: SyntaxNode): SyntaxNode[] { const result: SyntaxNode[] = []; let current: null | SyntaxNode = child; while (current !== null) { // result.unshift(current); // unshift would be used for [root, ..., child] if (current) { result.push(current); } current = current?.parent || null; } return result; } /** * Generator function for finding parent nodes. Default behavior is to exclude the child node passed in. * If you want to include the child node, pass in true as the second argument. * @param {SyntaxNode} child - the child node to start from * @param {boolean} [includeSelf] - if true, the child node is included in the results * @returns {Generator} - a generator that yields parent nodes */ export function* getParentNodesGen(child: SyntaxNode, includeSelf: boolean = false): Generator { let current: null | SyntaxNode = includeSelf ? child : child.parent; while (current !== null) { yield current; current = current.parent; } } /** * Generator function for finding child nodes. Default behavior is to exclude the parent node passed in. */ export function* nodesGen(node: SyntaxNode) { const queue: SyntaxNode[] = [node]; while (queue.length) { const n = queue.shift(); if (!n) { return; } if (n.children.length) { queue.unshift(...n.children); } yield n; } } export function* namedNodesGen(node: SyntaxNode) { const queue: SyntaxNode[] = [node]; while (queue.length) { const n = queue.shift(); if (!n) { continue; } if (n.children.length) { queue.unshift(...n.children); } // Skip unnamed nodes but continue processing the queue if (!n.isNamed) { continue; } yield n; } } export function findFirstParent(node: SyntaxNode, predicate: (node: SyntaxNode) => boolean): SyntaxNode | null { let current: SyntaxNode | null = node.parent; while (current !== null) { if (predicate(current)) { return current; } current = current.parent; } return null; } /** * collects all siblings either before or after the current node. * * @param {SyntaxNode} node - the node to start from * @param {'forward' | 'backward'} [lookForward] - if 'backward' (DEFAULT), looks nodes after the current node. * otherwise if specified false, looks for nodes before the current node. * @returns {SyntaxNode[]} - an array of either previous siblings or next siblings. */ export function getSiblingNodes( node: SyntaxNode, predicate: (n: SyntaxNode) => boolean, direction: 'before' | 'after' = 'before', ): SyntaxNode[] { const siblingFunc = (n: SyntaxNode) => direction === 'before' ? n.previousNamedSibling : n.nextNamedSibling; let current: SyntaxNode | null = node; const result: SyntaxNode[] = []; while (current) { current = siblingFunc(current); if (current && predicate(current)) { result.push(current); } } return result; } /** * Similar to getSiblingNodes. Only returns first node matching the predicate */ export function findFirstNamedSibling( node: SyntaxNode, predicate: (n: SyntaxNode) => boolean, direction: 'before' | 'after' = 'before', ): SyntaxNode | null { const siblingFunc = (n: SyntaxNode) => direction === 'before' ? n.previousNamedSibling : n.nextNamedSibling; let current: SyntaxNode | null = node; while (current) { current = siblingFunc(current); if (current && predicate(current)) { return current; } } return null; } export function findFirstSibling( node: SyntaxNode, predicate: (n: SyntaxNode) => boolean, direction: 'before' | 'after' = 'before', ): SyntaxNode | null { const siblingFunc = (n: SyntaxNode) => direction === 'before' ? n.previousSibling : n.nextSibling; let current: SyntaxNode | null = node; while (current) { current = siblingFunc(current); if (current && predicate(current)) { return current; } } return null; } const findFirstParentFunctionOrProgram = (parent: SyntaxNode) => { const result = findFirstParent(parent, n => isFunctionDefinition(n) || isProgram(n)); if (result) { return result; } return parent; }; export function findEnclosingScope(node: SyntaxNode): SyntaxNode { let parent = node.parent || node; if (isFunctionDefinitionName(node)) { return findFirstParentFunctionOrProgram(parent); } else if (node.text === 'argv') { parent = findFirstParentFunctionOrProgram(parent); return isFunctionDefinition(parent) ? parent.firstNamedChild || parent : parent; } else if (isVariable(node)) { parent = findFirstParent(node, n => isScope(n)) || parent; return isForLoop(parent) && findForLoopVariable(parent)?.text === node.text ? parent : findFirstParent(node, n => isProgram(n) || isFunctionDefinitionName(n)) || parent; } else if (isCommandName(node)) { return findFirstParent(node, n => isProgram(n)) || parent; } else { return findFirstParent(node, n => isScope(n)) || parent; } } // some nodes (such as commands) to get their text, you will need // the first named child. // other nodes (such as flags) need just the actual text. export function getNodeText(node: SyntaxNode | null): string { if (!node) { return ''; } if (isFunctionDefinition(node)) { return node.child(1)?.text || ''; } if (isVariableDefinition(node)) { const defVar = findSetDefinedVariable(node)!; return defVar.text || ''; } return node.text !== null ? node.text.trim() : ''; } export function getNodesTextAsSingleLine(nodes: SyntaxNode[]): string { let text = ''; for (const node of nodes) { text += ' ' + node.text.split('\n').map(n => n.split(' ').map(n => n.trim()).join(' ')).map(n => n.trim()).join(';'); if (!text.endsWith(';')) { text += ';'; } } return text.replaceAll(/;+/g, ';').trim(); } export function firstAncestorMatch( start: SyntaxNode, predicate: (n: SyntaxNode) => boolean, ): SyntaxNode | null { const ancestors = getParentNodes(start) || []; const root = ancestors[ancestors.length - 1]; //if (ancestors.length < 1) return root; for (const p of ancestors) { if (!predicate(p)) { continue; } return p; } return !!root && predicate(root) ? root : null; } /** * finds all ancestors (parent nodes) of a node that match a predicate * * @param {SyntaxNode} start - the leaf/deepest child node to start searching from * @param {(n: SyntaxNode) => boolean} predicate - a function that returns true if the node matches * @param {boolean} [inclusive] - if true, the start node can be included in the results * @returns {SyntaxNode[]} - an array of nodes that match the predicate */ export function ancestorMatch( start: SyntaxNode, predicate: (n: SyntaxNode) => boolean, inclusive: boolean = true, ): SyntaxNode[] { const ancestors = getParentNodes(start) || []; const searchNodes: SyntaxNode[] = []; for (const p of ancestors) { searchNodes.push(...getChildNodes(p)); } const results: SyntaxNode[] = searchNodes.filter(neighbor => predicate(neighbor)); return inclusive ? results : results.filter(ancestor => ancestor !== start); } /** * searches for all children nodes that match the predicate passed in * * @param {SyntaxNode} start - the root node to search from * @param {(n: SyntaxNode) => boolean} predicate - a function that returns a bollean * incating whether the node passed in matches the search criteria * @param {boolean} inclusive: boolean = true, * @returns {SyntaxNode[]} - all child nodes that match the predicate */ export function descendantMatch( start: SyntaxNode, predicate: (n: SyntaxNode) => boolean, inclusive = true, ): SyntaxNode[] { const descendants: SyntaxNode[] = []; descendants.push(...getChildNodes(start)); const results = descendants.filter(descendant => predicate(descendant)); return inclusive ? results : results.filter(r => r !== start); } export function hasNode(allNodes: SyntaxNode[], matchNode: SyntaxNode) { for (const node of allNodes) { if (node.equals(matchNode)) { return true; } } return false; } export function getNamedNeighbors(node: SyntaxNode): SyntaxNode[] { return node.parent?.namedChildren || []; } export function getRange(node: SyntaxNode): Range { return Range.create( node.startPosition.row, node.startPosition.column, node.endPosition.row, node.endPosition.column, ); } /** * Formats a SyntaxNode for logging purposes * @example * ```typescript * logger.log({ * root: nodeLogFormatter(tree.rootNode), * currentNode: nodeLogFormatter(currentNode), * }) * ``` * @returns a object with type, text, and range of the node */ export function nodeLogFormatter(node: SyntaxNode | null) { if (!node) { return { type: 'null', text: 'null', range: 'null:null', }; } return { type: node.type, text: node.text, range: `${node.startPosition.row}:${node.startPosition.column}-${node.endPosition.row}:${node.endPosition.column}`, }; } /** * findNodeAt() - handles moving backwards if the cursor is not currently on a node (safer version of getNodeAt) */ export function findNodeAt(tree: Tree, line: number, column: number): SyntaxNode | null { if (!tree.rootNode) { return null; } let currentCol = column; const currentLine = line; while (currentLine > 0) { const currentNode = tree.rootNode.descendantForPosition({ row: currentLine, column: currentCol }); if (currentNode) { return currentNode; } currentCol--; } return tree.rootNode.descendantForPosition({ row: line, column }); } export function equalRanges(a: Range, b: Range): boolean { return ( a.start.line === b.start.line && a.start.character === b.start.character && a.end.line === b.end.line && a.end.character === b.end.character ); } /** * Check if a range contains otherRange. * @param outer - The range that should contain the other range. * @param inner - The range that should be contained by the other range. * @returns `true` if `range` contains `otherRange`. */ export function containsRange(outer: Range, inner: Range): boolean { if (inner.start.line < outer.start.line || inner.end.line < outer.start.line) { return false; } if (inner.start.line > outer.end.line || inner.end.line > outer.end.line) { return false; } if (inner.start.line === outer.start.line && inner.start.character < outer.start.character) { return false; } if (inner.end.line === outer.end.line && inner.end.character > outer.end.character) { return false; } return true; } /** * @param before - The range that should precede the other range. * @param after - The range that should follow the other range. * @returns `true` if `before` precedes `after`. */ export function precedesRange(before: Range, after: Range): boolean { if (before.start.line < after.start.line) { return true; } if (before.start.line === after.start.line && before.start.character < after.start.character) { return true; } return false; } /** * getNodeAt() - handles moving backwards if the cursor i */ export function getNodeAt(tree: Tree, line: number, column: number): SyntaxNode | null { if (!tree.rootNode) { return null; } return tree.rootNode.descendantForPosition({ row: line, column }); } /** * Check if a node contains otherNode. * @param outer - The outer node that should contain the other node. * @param inner - The inner node that should be contained by the outer node. * @returns `true` if `node` contains `otherNode`. */ export function containsNode(outer: SyntaxNode, inner: SyntaxNode): boolean { return containsRange(getRange(outer), getRange(inner)); } export function getNodeAtRange(root: SyntaxNode, range: Range): SyntaxNode | null { return root.descendantForPosition( positionToPoint(range.start), positionToPoint(range.end), ); } export function positionToPoint(pos: Position): Point { return { row: pos.line, column: pos.character, }; } export function pointToPosition(point: Point): Position { return { line: point.row, character: point.column, }; } export function rangeToPoint(range: Range): Point { return { row: range.start.line, column: range.start.character, }; } export function getRangeWithPrecedingComments(node: SyntaxNode): Range { let currentNode: SyntaxNode | null = node.previousNamedSibling; let previousNode: SyntaxNode = node; while (currentNode?.type === 'comment') { previousNode = currentNode; currentNode = currentNode.previousNamedSibling; } return Range.create( pointToPosition(previousNode.startPosition), pointToPosition(node.endPosition), ); } export function getPrecedingComments(node: SyntaxNode | null): string { if (!node) { return ''; } const comments = commentsHelper(node); if (!comments) { return node.text; } return [ commentsHelper(node), node.text, ].join('\n'); } function commentsHelper(node: SyntaxNode | null): string { if (!node) { return ''; } const comment: string[] = []; let currentNode = node.previousNamedSibling; while (currentNode?.type === 'comment') { //comment.unshift(currentNode.text.replaceAll(/#+\s?/g, '')) comment.unshift(currentNode.text); currentNode = currentNode.previousNamedSibling; } return comment.join('\n'); } export function isFishExtension(path: URI | string): boolean { const ext = extname(path).toLowerCase(); return ext === '.fish'; } export function isPositionWithinRange(position: Position, range: Range): boolean { const doesStartInside = position.line > range.start.line || position.line === range.start.line && position.character >= range.start.character; const doesEndInside = position.line < range.end.line || position.line === range.end.line && position.character <= range.end.character; return doesStartInside && doesEndInside; } export function isPositionAfter(first: Position, second: Position): boolean { return ( first.line < second.line || first.line === second.line && first.character < second.character ); } export function isNodeWithinRange(node: SyntaxNode, range: Range): boolean { const doesStartInside = node.startPosition.row > range.start.line || node.startPosition.row === range.start.line && node.startPosition.column >= range.start.character; const doesEndInside = node.endPosition.row < range.end.line || node.endPosition.row === range.end.line && node.endPosition.column <= range.end.character; return doesStartInside && doesEndInside; } export function isNodeWithinOtherNode(node: SyntaxNode, otherNode: SyntaxNode): boolean { return isNodeWithinRange(node, getRange(otherNode)); } /** * Checks if a server position is within a tree-sitter node */ export function isPositionInNode(position: Position, node: SyntaxNode): boolean { const start = node.startPosition; const end = node.endPosition; // Check if position is before the node if (position.line < start.row) return false; if (position.line === start.row && position.character < start.column) return false; // Check if position is after the node if (position.line > end.row) return false; if (position.line === end.row && position.character > end.column) return false; return true; } export function getLeafNodes(node: SyntaxNode): SyntaxNode[] { function gatherLeafNodes(node: SyntaxNode, leafNodes: SyntaxNode[] = []): SyntaxNode[] { if (node.childCount === 0 && node.text !== '') { leafNodes.push(node); return leafNodes; } for (const child of node.children) { leafNodes = gatherLeafNodes(child, leafNodes); } return leafNodes; } return gatherLeafNodes(node); } export function getLastLeafNode(node: SyntaxNode, maxIndex: number = Infinity): SyntaxNode { const allLeafNodes = getLeafNodes(node).filter(leaf => leaf.startPosition.column < maxIndex); return allLeafNodes[allLeafNodes.length - 1]!; } export function getNodeAtPosition(tree: Tree, position: { line: number; character: number; }): SyntaxNode | null { return tree.rootNode.descendantForPosition({ row: position.line, column: position.character }); } /** * Tree traversal utilities for functional composition and null-safe operations * * Provides methods to traverse syntax trees in a functional manner, * eliminating repetitive while loops and null checking patterns. * * @example * ```typescript * // Instead of: * let current = node.parent; * while (current) { * if (predicate(current)) { * return current; * } * current = current.parent; * } * return null; * * // Use: * TreeWalker.walkUp(node, predicate).getOrElse(null); * ``` */ export class TreeWalker { /** * Walk up the tree until a node matching the predicate is found */ static walkUp(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean): Maybe { let current = node.parent; while (current) { if (predicate(current)) { return Maybe.of(current); } current = current.parent; } return Maybe.none(); } /** * Walk up the tree and collect all nodes matching the predicate */ static walkUpAll(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean): SyntaxNode[] { const results: SyntaxNode[] = []; let current = node.parent; while (current) { if (predicate(current)) { results.push(current); } current = current.parent; } return results; } /** * Find the first child node matching the predicate */ static findFirstChild(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean): Maybe { const child = node.namedChildren.find(predicate); return Maybe.of(child); } /** * Find the highest (farthest from start node) ancestor matching the predicate */ static findHighest(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean): Maybe { const all = TreeWalker.walkUpAll(node, predicate); return Maybe.of(all[all.length - 1]); } /** * Walk down the tree breadth-first until a node matching the predicate is found */ static walkDown(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean): Maybe { const queue: SyntaxNode[] = [node]; while (queue.length > 0) { const current = queue.shift()!; if (predicate(current)) { return Maybe.of(current); } queue.push(...current.namedChildren); } return Maybe.none(); } /** * Walk down the tree and collect all nodes matching the predicate */ static walkDownAll(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean): SyntaxNode[] { const results: SyntaxNode[] = []; const queue: SyntaxNode[] = [node]; while (queue.length > 0) { const current = queue.shift()!; if (predicate(current)) { results.push(current); } queue.push(...current.namedChildren); } return results; } } ================================================ FILE: src/utils/workspace-manager.ts ================================================ import { DocumentUri, WorkDoneProgressServerReporter, WorkspaceFoldersChangeEvent } from 'vscode-languageserver'; import { logger } from '../logger'; import { FishUriWorkspace, Workspace, WorkspaceUri } from './workspace'; import { documents, LspDocument } from '../document'; import { analyzer, AnalyzedDocument } from '../analyze'; import { config } from '../config'; import { isPath, PathLike, pathToUri, uriToPath } from './translation'; import { ProgressNotification } from './progress-notification'; type WorkspaceUpdateOptions = { analyzedDocument?: AnalyzedDocument; /** * Skip re-running analyzer.analyze(). The caller is responsible for ensuring * the cache already contains the latest document state when this is true. */ skipAnalysis?: boolean; }; export class WorkspaceManager { private stack: WorkspaceStack = new WorkspaceStack(); private allWorkspaces: Map = new Map(); /** * Method to copy the current workspace manager (for testing purposes). */ public copy(workspaceManager: WorkspaceManager) { this.allWorkspaces = new Map(workspaceManager.allWorkspaces); this.stack = this.stack.copy(workspaceManager.stack); return this; } /** * Set the current workspace to the given workspace. * This method will add the workspace to the history stack and include it in the map of all workspaces. * A workspace that is already stored in the history stack will be removed from its old index, and * set to the top of the stack. */ public setCurrent(workspace: Workspace) { this.allWorkspaces.set(workspace.uri, workspace); this.stack.push(workspace); return this.stack.current; } /** * Get the current workspace, if it exists. */ public get current(): Workspace | undefined { return this.stack.current; } // adds a workspace to the map of all workspaces, but does not add it to the stack // that stores the current workspace public add(...workspaces: Workspace[]): void { workspaces.forEach((workspace) => { if (this.allWorkspaces.has(workspace.uri)) { return; } this.allWorkspaces.set(workspace.uri, workspace); }); } // removes a workspace from the map of all workspaces and the history stack public remove(...workspaces: Workspace[]): void { workspaces.forEach((w) => { if (this.allWorkspaces.has(w.uri)) { this.allWorkspaces.delete(w.uri); } }); this.stack.remove(...workspaces); } public findContainingWorkspace(uri: DocumentUri): Workspace | null; public findContainingWorkspace(docPath: PathLike): Workspace | null; public findContainingWorkspace(document: LspDocument): Workspace | null; public findContainingWorkspace(doc: DocumentUri | LspDocument): Workspace | null; public findContainingWorkspace(doc: DocumentUri | LspDocument): Workspace | null { const documentUri = this.getDocumentUriFromParams(doc); return this.getWorkspaceContainingUri(documentUri); } public hasContainingWorkspace(uri: DocumentUri): boolean; public hasContainingWorkspace(docPath: PathLike): boolean; public hasContainingWorkspace(document: LspDocument): boolean; public hasContainingWorkspace(doc: DocumentUri | LspDocument): boolean; public hasContainingWorkspace(doc: DocumentUri | LspDocument): boolean { const documentUri = this.getDocumentUriFromParams(doc); return this.allWorkspaces.has(documentUri); } /** * Removes any workspace that is stored in this class (useful for testing). */ public clear(): this { this.allWorkspaces.clear(); this.stack.clear(); return this; } /** * Get an array of all the workspaces that are currently stored in this class. * The resulting array will be sorted by workspaces opened most recently, followed * by the workspaces that are not opened but are still indexed. */ public get all() { const uniqueWorkspaces = new Set(); const result: Workspace[] = []; this.stack.allOpened.forEach((workspace) => { result.push(workspace); uniqueWorkspaces.add(workspace.uri); }); this.allWorkspaces.forEach((workspace) => { if (!uniqueWorkspaces.has(workspace.uri)) { result.push(workspace); uniqueWorkspaces.add(workspace.uri); } }); return result; } /** * get all document uris across all workspaces */ public get allUrisInAllWorkspaces(): DocumentUri[] { const result: DocumentUri[] = []; this.all.forEach((workspace) => { result.push(...Array.from(workspace.allUris)); }); return result; } /** * get all workspaces that need indexing to be done to their documents */ public workspacesToAnalyze(): Workspace[] { return this.all.filter((workspace) => workspace.needsAnalysis()); } /** * Checks if any workspace exists which needs to be analyzed by the analyzePendingDocuments() method. */ public needsAnalysis(): boolean { return this.workspacesToAnalyze().length > 0; } /** * Get all workspaces that contain the given document (since a document can be in multiple workspaces). */ public allWorkspacesWithDocument(doc: LspDocument): Workspace[] { return this.all.filter((workspace) => workspace.contains(doc.uri)); } /** * Get all documents that need analysis across all workspaces. * This method is used to find documents that are pending analysis. * The resulting documents are unique (i.e., documents in multiple workspaces are not duplicated). */ public allAnalysisDocuments(): LspDocument[] { const uniqueUris = new Set(); const result: LspDocument[] = []; for (const workspace of this.workspacesToAnalyze()) { const pendingDocuments = workspace.pendingDocuments(); pendingDocuments.forEach((doc) => { if (!uniqueUris.has(doc.uri)) { uniqueUris.add(doc.uri); result.push(doc); } }); } return result; } public get isLargeAnalysis(): boolean { return this.allAnalysisDocuments().length > 25; } public findDocumentInAnyWorkspace(uri: DocumentUri): LspDocument | null { for (const workspace of this.all) { const doc = workspace.findDocument(d => d.uri === uri); if (doc) return doc; } return null; } /** * Check if the workspace manager already has a workspace that contains the given URI. */ private getWorkspaceContainingUri(uri: DocumentUri): Workspace | null { // First check if any workspace already contains this URI const directMatch = this.all.find((workspace) => workspace.uris.has(uri) || workspace.uri === uri, ); if (directMatch) return directMatch; // For funced files, check if we have a workspace that matches the funced workspace root const uriPath = uriToPath(uri); if (LspDocument.isFuncedPath(uriPath) || LspDocument.isCommandlineBufferPath(uriPath)) { const rootWorkspace = FishUriWorkspace.create(uri); if (rootWorkspace) { // Find the workspace that matches the funced workspace's root return this.all.find((workspace) => workspace.uri === rootWorkspace.uri, ) || null; } } return null; } /** * Get the existing workspace or create a new one if it doesn't exist. * This method is used to handle the case where a document is opened or edited. */ private getExistingWorkspaceOrCreateNew(uri: DocumentUri): Workspace | null { const existingWorkspace = this.getWorkspaceContainingUri(uri); if (existingWorkspace) return existingWorkspace; const newWorkspace = Workspace.syncCreateFromUri(uri); if (!newWorkspace) { logger.error(`Failed to create workspace from URI: ${uri}`); return null; } return newWorkspace; } /** * Get the document URI from the given parameters. */ private getDocumentUriFromParams(document: LspDocument): string; private getDocumentUriFromParams(documentUri: DocumentUri): string; private getDocumentUriFromParams(documentPath: PathLike): string; private getDocumentUriFromParams(param: DocumentUri | LspDocument | PathLike): string; private getDocumentUriFromParams(param: DocumentUri | LspDocument | PathLike): string { if (LspDocument.is(param)) return param.uri.toString(); if (DocumentUri.is(param)) return param.toString(); if (isPath(param)) return pathToUri(param).toString(); return ''; } /** * Handle the opening of a document. * This method is used to open the document in the documents manager, analyze it, * set the current workspace, then add the sourced uris to the workspace, lastly * analyze the workspace if needed. */ public handleOpenDocument(document: LspDocument): Workspace | null; public handleOpenDocument(documentUri: DocumentUri): Workspace | null; public handleOpenDocument(documentUri: DocumentUri | LspDocument): Workspace | null; public handleOpenDocument(doc: DocumentUri | LspDocument): Workspace | null { const documentUri = this.getDocumentUriFromParams(doc); logger.info('workspaceManager.handleOpenDocument()', 'Opening document', { params: { uri: this.getDocumentUriFromParams(doc), type: LspDocument.is(doc) ? 'LspDocument' : 'DocumentUri', version: LspDocument.is(doc) ? doc.version : undefined, }, }); documents.get(documentUri); const document = documents.get(documentUri); const newWorkspace = this.getExistingWorkspaceOrCreateNew(documentUri); if (!newWorkspace || !document) { logger.error( 'workspaceManager.handleOpenDocument()', `Failed to create or find workspace for URI: ${documentUri}`, { params: doc }, ); return null; } analyzer.analyze(document); newWorkspace.add(...Array.from(analyzer.collectAllSources(documentUri))); this.setCurrent(newWorkspace); // Mark workspace as needing analysis, but DON'T analyze synchronously here // The background analysis in onInitialized will pick it up if (newWorkspace.needsAnalysis()) { logger.info(`workspaceManager.handleOpenDocument() - Workspace('${newWorkspace.name}').needsAnalysis() - will be analyzed in background`); // REMOVED: analyzer.analyzeWorkspace(newWorkspace); // This synchronous call blocked the main thread and happened before progress reporting started } return this.current as Workspace; } /** * Handle the closing of a document. * This method is used to remove the document from the workspace and close it in the documents manager. */ public handleCloseDocument(document: LspDocument): Workspace | null; public handleCloseDocument(documentUri: DocumentUri): Workspace | null; public handleCloseDocument(doc: DocumentUri | LspDocument): Workspace | null; public handleCloseDocument(doc: DocumentUri | LspDocument): Workspace | null { const documentUri = this.getDocumentUriFromParams(doc); logger.info('workspaceManager.handleCloseDocument()', 'Closing document', { params: { uri: documentUri, type: LspDocument.is(doc) ? 'LspDocument' : 'DocumentUri', version: LspDocument.is(doc) ? doc.version : undefined, }, }); const totalUrisBeforeRemoval = this.allUrisInAllWorkspaces.length; const workspace = this.getWorkspaceContainingUri(documentUri); documents.all().splice( documents.all().findIndex(d => d.uri === documentUri), ); if (!workspace) { logger.error( 'workspaceManager.handleCloseDocument()', `Failed to find workspace for URI: ${documentUri}`, { params: doc }, ); return null; } const docsInWorkspace = documents.all().filter(doc => workspace.contains(doc.uri) && this.allWorkspacesWithDocument(doc).length === 1, ); if (docsInWorkspace.length === 0) this.remove(workspace); logger.info('workspaceManager.handleCloseDocument()', { priorToRemoval: totalUrisBeforeRemoval, removedUris: workspace.allUris.size, remainingUris: this.allUrisInAllWorkspaces.length, currentWorkspace: this.current?.name, removedWorkspaces: workspace.name, removedDocument: documentUri, currentDocuments: documents.all().map((doc) => doc.uri), }); return this.current || null; } /** * Handle updating the current workspace when a document is updated * Does not handle updating the document itself. */ public handleUpdateDocument(document: LspDocument, options?: WorkspaceUpdateOptions): Workspace | null; public handleUpdateDocument(documentUri: DocumentUri, options?: WorkspaceUpdateOptions): Workspace | null; public handleUpdateDocument(doc: DocumentUri | LspDocument, options: WorkspaceUpdateOptions = {}): Workspace | null { logger.info('workspaceManager.handleUpdateDocument()', 'Updating document:', { doc: { uri: this.getDocumentUriFromParams(doc), type: LspDocument.is(doc) ? 'LspDocument' : 'DocumentUri', version: LspDocument.is(doc) ? doc.version : undefined, }, }); const documentUri = this.getDocumentUriFromParams(doc); const workspace = this.getExistingWorkspaceOrCreateNew(documentUri); if (!workspace) { logger.error( 'workspaceManager.handleUpdateDocument()', `Failed to find workspace for URI: ${documentUri}`, ); return null; } this.setCurrent(workspace); const document = documents.get(documentUri); if (document) { let analyzedDocument = options.analyzedDocument; if (!analyzedDocument && options.skipAnalysis) { analyzedDocument = analyzer.cache.getDocument(document.uri); } if (!analyzedDocument) { analyzer.removeDocumentSymbols(document.uri); analyzedDocument = analyzer.analyze(document); } if (analyzedDocument) { workspace.addPending(documentUri); workspace.addPending(...Array.from(analyzer.collectAllSources(documentUri))); const localSymbols = analyzer.cache.getDocumentSymbols(document.uri); const sourcedSymbols = analyzer.collectSourcedSymbols(document.uri); [...localSymbols, ...sourcedSymbols] .filter(s => s.isGlobal() || s.isRootLevel()) .forEach(s => { analyzer.globalSymbols.add(s); }); } } return this.current!; } /** * Handle the workspace change event, which is triggered when a workspace is added or removed * This method will update the map of all workspaces and the resulting workspaces will be * re-analyzed. */ public handleWorkspaceChangeEvent(event: WorkspaceFoldersChangeEvent, progress?: WorkDoneProgressServerReporter | ProgressNotification): void { progress?.begin('[fish-lsp] indexing files', 0, `Analyzing workspaces [+${event.added.length} | -${event.removed.length}]`, true); logger.info( 'workspaceManager.handleWorkspaceChangeEvent()', `Workspace change event: { added: ${event.added.length}, removed: ${event.removed.length} } `, { added: event.added.map((ws) => ws.uri), removed: event.removed.map((ws) => ws.uri), }, ); event.added.forEach((workspace) => { const foundWorkspace = this.getExistingWorkspaceOrCreateNew(workspace.uri); if (foundWorkspace) { this.add(foundWorkspace); } else { logger.warning( 'workspaceManager.handleWorkspaceChangeEvent()', `FAILED: event.added: ${workspace.uri} `, ); } }); event.removed.forEach((workspace) => { const foundWorkspace = this.getExistingWorkspaceOrCreateNew(workspace.uri); if (foundWorkspace) { this.remove(foundWorkspace); } else { logger.warning( 'workspaceManager.handleWorkspaceChangeEvent()', `FAILED event.removed: ${workspace.uri} `, ); } }); } /** * Analyze all documents that need analysis, across all workspaces. * ___ * * NOTE: if the user sets an arbitrarily low value for fish_lsp_max_background_files, this method will need to be called multiple times. * * ```typescript * while (workspaceManager.needsAnalysis()) { * workspaceManager.analyzePendingDocuments(); * } * ``` * ___ * @param progress - Optional progress wrapper to report progress. * @param callbackfn - Optional callback function to handle progress messages. * @returns An object containing the analyzed items, total documents, and duration of analysis. */ public async analyzePendingDocuments( progress: WorkDoneProgressServerReporter | ProgressNotification | undefined = undefined, callbackfn: (str: string) => void = (s) => logger.log(s), ) { logger.info('workspaceManager.analyzePendingDocuments()'); const items: { [workspacePath: PathLike]: string[]; } = {}; const startTime = performance.now(); // get all documents that need analysis const pendingDocuments = this.allAnalysisDocuments(); const maxSize = Math.min(pendingDocuments.length, config.fish_lsp_max_background_files); const currentDocuments = 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 // Process documents in batches for (let idx = 0; idx < currentDocuments.length; idx++) { const doc = currentDocuments[idx]!; // Process the document const workspaces = this.allWorkspacesWithDocument(doc); workspaces.forEach((workspace) => { workspace.uris.markIndexed(doc.uri); const uris = items[workspace.path] || []; uris.push(doc.uri); items[workspace.path] = uris; }); try { if (doc.getAutoloadType() === 'completions') { analyzer.analyzePartial(doc); } else { analyzer.analyze(doc); } } catch (error) { logger.error( 'workspaceManager.analyzePendingDocuments()', `Error analyzing document: ${doc.uri} `, { error }, ); } // Only update progress on batch completion or significant percentage change 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); const message = `Analyzing ${idx + 1}/${maxSize} ${maxSize > 1 ? 'documents' : 'document'}`; // Report with both percentage number and descriptive message progress?.report(percentage, message); lastUpdateTime = currentTime; // Add a small delay for visual perception await delay(UPDATE_DELAY); } } const endTime = performance.now(); const duration = ((endTime - startTime) / 1000).toFixed(5); const message = `Analyzed ${currentDocuments.length} document${currentDocuments.length > 1 ? 's' : ''} in ${duration}s`; callbackfn(message); logger.info( 'workspaceManager.analyzePendingDocuments()', message, { duration: `${duration} s`, totalDocuments: currentDocuments.length, maxSize, }, ); return { items, totalDocuments: currentDocuments.length, duration: (endTime - startTime) / 1000, }; } } /*** * A utility class to manage history ordering of workspaces. * * When a workspace is opened, it is pushed to the top of the stack. * * A workspace that is already in the stack will be removed from its old index, and * set to the top of the stack (items in the stack are unique workspaces). * * When a workspace is closed, it is removed from the stack. The stack then allows * for the server to set the current workspace to the last opened workspace. * * The top of the stack is the last indexed item, and the bottom of the stack is the * first indexed item. This is the reason for the `toReversed()` usage when the * `allOpened` method is called. The allOpened method allows for iterating over the * workspace history in the order of most to least recently opened workspaces. */ class WorkspaceStack { private stack: Workspace[] = []; public copy(workspaceStack: WorkspaceStack) { this.stack = [...workspaceStack.stack]; return this; } public push(workspace: Workspace): void { if (this.has(workspace)) this.remove(workspace); this.stack.push(workspace); } public pop(): Workspace | undefined { return this.stack.pop(); } public get current(): Workspace | undefined { return this.stack[this.stack.length - 1]; } public get allOpened(): Workspace[] { return this.stack.toReversed(); } public findIndex(workspace: Workspace): number { return this.stack.findIndex((w) => w.uri === workspace.uri); } public has(workspace: Workspace): boolean { return this.stack.some((w) => w.uri === workspace.uri); } public isEmpty(): boolean { return this.stack.length === 0; } public clear(): void { this.stack = []; } public get length(): number { return this.stack.length; } public remove(...workspaces: Workspace[]): void { this.stack = this.stack.filter((w) => !workspaces.some((ws) => ws.equals(w)), ); } } /** * The global singleton instance of the workspace manager. * Use this object to: * - retrieve the current workspace * - update the current workspace * - add or remove new workspaces, * - analyze pending documents, across all workspaces * - maintain workspace ordering based on recency of opening/closing */ export const workspaceManager = new WorkspaceManager(); ================================================ FILE: src/utils/workspace.ts ================================================ import * as fastGlob from 'fast-glob'; import fs from 'fs'; import path, { basename, dirname, join } from 'path'; import * as LSP from 'vscode-languageserver'; import { DocumentUri } from 'vscode-languageserver'; import { AnalyzedDocument, analyzer } from '../analyze'; import { config } from '../config'; import { LspDocument } from '../document'; import { logger } from '../logger'; import { FishSymbol } from '../parsing/symbol'; import { env } from './env-manager'; import { SyncFileHelper } from './file-operations'; import { pathToUri, uriToPath } from './translation'; import { workspaceManager } from './workspace-manager'; export type AnalyzedWorkspace = { uri: string; content: string; doc: LspDocument; result: AnalyzedDocument; }[]; export type AnalyzeWorkspacePromise = Promise<{ uri: string; content: string; doc: LspDocument; result: AnalyzedDocument; }>[]; /** * Extracts the unique workspace paths from the initialization parameters. * @param params - The initialization parameters * @returns The unique workspace paths given in the initialization parameters */ export function getWorkspacePathsFromInitializationParams(params: LSP.InitializeParams): string[] { const result: string[] = []; const { rootUri, rootPath, workspaceFolders } = params; logger.log('getWorkspacePathsFromInitializationParams(params)', { rootUri, rootPath, workspaceFolders }); // consider removing rootUri and rootPath since they are deprecated if (rootUri) { result.push(uriToPath(rootUri)); } if (rootPath) { result.push(rootPath); } if (workspaceFolders) { result.push(...workspaceFolders.map(folder => uriToPath(folder.uri))); } return Array.from(new Set(result)); } export async function getFileUriSet(path: string) { try { const stream = fastGlob.stream('**/*.fish', { cwd: path, absolute: true, suppressErrors: true, ignore: config.fish_lsp_ignore_paths, deep: config.fish_lsp_max_workspace_depth, onlyFiles: true, }); const result: Set = new Set(); for await (const entry of stream) { const absPath = entry.toString(); if (SyncFileHelper.isDirectory(absPath) || !SyncFileHelper.read(absPath)) { continue; } const uri = pathToUri(absPath); result.add(uri); } return result; } catch (error) { logger.debug('getFileUriSet: Error reading directory', { path, error }); return new Set(); } } export function syncGetFileUriSet(path: string) { try { const result: Set = new Set(); const entries = fastGlob.sync('**/*.fish', { cwd: path, absolute: true, suppressErrors: true, deep: config.fish_lsp_max_workspace_depth, ignore: config.fish_lsp_ignore_paths, onlyFiles: true, }); for (const entry of entries) { const absPath = entry.toString(); if (SyncFileHelper.isDirectory(absPath) || !SyncFileHelper.read(absPath)) { continue; } const uri = pathToUri(absPath); result.add(uri); } return result; } catch (error) { logger.debug('syncGetFileUriSet: Error reading directory', { path, error }); return new Set(); } } /** * Initializes the default fish workspaces. Does not control the currentWorkspace, only sets it up. * * UPDATES the `config.fish_lsp_single_workspace_support` if user sets it to true, and no workspaces are found (`/tmp` workspace will cause this). * * @param uris - The uris to initialize the workspaces with, if any * @returns The workspaces that were initialized, or an empty array if none were found (unlikely) */ export async function initializeDefaultFishWorkspaces(...uris: string[]): Promise { /** Compute the newWorkaces from the uris, before building if the configWorkspaces */ const newWorkspaces = uris.map(uri => { return FishUriWorkspace.create(uri); }).filter((ws): ws is FishUriWorkspace => ws !== null); const tmpConfigWorkspaces = FishUriWorkspace.initializeEnvWorkspaces(); let configWorkspaces = tmpConfigWorkspaces.filter(ws => !newWorkspaces.some(newWs => newWs.uri === ws.uri), ); const singleWorkspaceModeEnabled = config.fish_lsp_single_workspace_support === true; if (singleWorkspaceModeEnabled && newWorkspaces.length > 0) { const activeWorkspacePaths = new Set(newWorkspaces.map(ws => ws.path)); const narrowedConfigWorkspaces = configWorkspaces.filter(ws => activeWorkspacePaths.has(ws.path)); if (narrowedConfigWorkspaces.length !== configWorkspaces.length) { logger.info('initializeDefaultFishWorkspaces() narrowing indexed paths for single-workspace support', { requestedWorkspaces: Array.from(activeWorkspacePaths), droppedConfigWorkspaces: configWorkspaces .filter(ws => !activeWorkspacePaths.has(ws.path)) .map(ws => ws.path), }); } configWorkspaces = narrowedConfigWorkspaces; } // merge both arrays but keep the unique uris in the order they were passed in const allWorkspaces = [ ...newWorkspaces, ...configWorkspaces, ].filter((workspace, index, self) => index === self.findIndex(w => w.uri === workspace.uri), ).map(({ name, uri, path }) => Workspace.create(name, uri, path)); // Wait for all promises to resolve const defaultSpaces = await Promise.all(allWorkspaces); const results = defaultSpaces.filter((ws): ws is Workspace => ws !== null); results.forEach((ws, idx) => { logger.info(`Initialized workspace '${ws.name}' @ ${idx}`, { name: ws.name, uri: ws.uri, path: ws.path, }); workspaceManager.add(ws); }); return results; } export type WorkspaceUri = string; export interface FishWorkspace extends LSP.WorkspaceFolder { name: string; uri: WorkspaceUri; path: string; uris: UriTracker; allUris: Set; contains(...checkUris: string[]): boolean; allDocuments(): LspDocument[]; } export class Workspace implements FishWorkspace { public name: string; public uri: WorkspaceUri; public path: string; public uris = new UriTracker(); public symbols: Map = new Map(); public static async create(name: string, uri: DocumentUri | WorkspaceUri, path: string) { const isDirectory = SyncFileHelper.isDirectory(path); let foundUris: Set = new Set(); if (isDirectory) { if (!path.startsWith('/tmp')) { foundUris = await getFileUriSet(path); } } else { foundUris = new Set([uri]); } return new Workspace(name, uri, path, foundUris); } public static syncCreateFromUri(uri: string) { const path = uriToPath(uri); try { const isDirectory = SyncFileHelper.isDirectory(path); const workspace = FishUriWorkspace.create(uri); if (!workspace) return null; let foundUris: Set = new Set(); if (isDirectory || SyncFileHelper.isDirectory(workspace.path)) { if (!workspace.path.startsWith('/tmp')) { foundUris = syncGetFileUriSet(workspace.path); } } else { foundUris = new Set([workspace.uri]); } return new Workspace(workspace.name, workspace.uri, workspace.path, foundUris); } catch (e) { logger.error('syncCreateFromUri', { uri, error: e }); return null; } } public constructor(name: string, uri: WorkspaceUri, path: string, fileUris: Set) { this.name = name; this.uri = uri; this.path = path; this.uris = UriTracker.create(...Array.from(fileUris)); } public get allUris(): Set { return this.uris.allAsSet(); } contains(...checkUris: DocumentUri[]): boolean { for (const uri of checkUris) { if (!this.uris.has(uri)) { return false; } } return true; } /** * mostly for testing, (i.e., when writing at test that doesn't actually put any *.fish uri into memory) * @param uri - the uri to check if the the workspace should contain * @returns true if the uri is inside the workspace (inside meaning the uri starts with the workspace uri) */ shouldContain(uri: DocumentUri) { return uri.startsWith(this.uri) && !this.uris.allAsSet().has(uri); } addUri(uri: DocumentUri) { this.uris.add(uri); } add(...newUris: DocumentUri[]) { this.uris.add(...newUris); } addDocument(...newDocs: LspDocument[]) { const newUris = newDocs.map(doc => doc.uri); this.uris.add(...newUris); } addPending(...newUris: DocumentUri[]) { this.uris.addPending(newUris); } findMatchingFishIdentifiers(fishIdentifier: string) { const matches: string[] = []; const toMatch = `/${fishIdentifier}.fish`; for (const uri of Array.from(this.uris.allAsSet())) { if (uri.endsWith(toMatch)) { matches.push(uri); } } return matches; } findDocument(callbackfn: (doc: LspDocument) => boolean): LspDocument | undefined { for (const uri of this.uris.all) { const doc = analyzer.getDocument(uri); if (doc && callbackfn(doc)) { return doc; } } return undefined; } /** * An immutable workspace would be '/usr/share/fish', since we don't want to * modify the system files. * * A mutable workspace would be '~/.config/fish' */ isMutable() { return config.fish_lsp_modifiable_paths.includes(this.path) || SyncFileHelper.isWriteable(this.path); } isLoadable() { return config.fish_lsp_all_indexed_paths.includes(this.path); } isAnalyzed() { return this.uris.pendingCount === 0 && this.allUris.size > 0; } hasCompletionUri(fishIdentifier: string) { const matchingUris = this.findMatchingFishIdentifiers(fishIdentifier); return matchingUris.some(uri => uri.endsWith(`/completions/${fishIdentifier}.fish`)); } hasFunctionUri(fishIdentifier: string) { const matchingUris = this.findMatchingFishIdentifiers(fishIdentifier); return matchingUris.some(uri => uri.endsWith(`/functions/${fishIdentifier}.fish`)); } hasCompletionAndFunction(fishIdentifier: string) { return this.hasFunctionUri(fishIdentifier) && this.hasCompletionUri(fishIdentifier); } getCompletionUri(fishIdentifier: string) { const matchingUris = this.findMatchingFishIdentifiers(fishIdentifier); return matchingUris.find(uri => uri.endsWith(`/completions/${fishIdentifier}.fish`)); } pendingDocuments(): LspDocument[] { const docs: LspDocument[] = []; for (const uri of this.uris.pending) { const path = uriToPath(uri); const doc = SyncFileHelper.loadDocumentSync(path); if (!doc) { logger.error('pendingDocuments', { uri, path }); continue; } docs.push(doc); } return docs; } allDocuments(): LspDocument[] { const docs: LspDocument[] = []; for (const uri of this.getUris()) { const analyzedDoc = analyzer.getDocument(uri); if (analyzedDoc) { docs.push(analyzedDoc); continue; } const path = uriToPath(uri); const doc = SyncFileHelper.loadDocumentSync(path); if (!doc) { logger.error('allDocuments', { uri, path }); continue; } docs.push(doc); } return docs; } get paths(): string[] { return Array.from(this.allUris).map(uri => uriToPath(uri)); } getUris(): DocumentUri[] { return Array.from(this.allUris || []); } equals(other: FishWorkspace | null) { if (!other) return false; return this.name === other.name && this.uri === other.uri && this.path === other.path; } public needsAnalysis() { return this.uris.pendingCount > 0; } setAllPending() { for (const uri of this.uris.all) { this.uris.markPending(uri); } } toTreeString() { 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(this.name + '/'); buildTree(this.path, ''); return tree.join('\n'); } showAllTreeSitterParseTrees() { const docs = this.allDocuments(); if (docs.length === 0) { logger.warning('No documents found in workspace', { name: this.name, uri: this.uri }); return; } docs.forEach(doc => { doc.showTree(); }); } } export interface FishUriWorkspace { name: string; uri: string; path: string; } export namespace FishUriWorkspace { /** special location names */ const FISH_DIRS = ['functions', 'completions', 'conf.d']; const CONFIG_FILE = 'config.fish'; export function isTmpWorkspace(uri: string) { const path = uriToPath(uri); return path.startsWith('/tmp'); } /** * Gets the fish config directory path for funced files or command-line buffers * * `funced ...` * `... # (PRESS 'edit_commandline_buffer' key) normally alt+e` * * Returns undefined if not a funced file */ export function getFuncedOrCommandlineWorkspaceRoot(): string | undefined { const fishConfigDir = env.get('__fish_config_dir'); return fishConfigDir; } /** * Removes file path component from a fish file URI unless it's config.fish */ export function trimFishFilePath(uri: string): string | undefined { const path = uriToPath(uri); if (!path) return undefined; const base = basename(path); if (base === CONFIG_FILE || path.startsWith('/tmp')) return path; return !SyncFileHelper.isDirectory(path) && base.endsWith('.fish') ? dirname(path) : path; } /** * Gets the workspace root directory from a URI */ export function getWorkspaceRootFromUri(uri: string): string | undefined { const path = uriToPath(uri); if (!path) return undefined; let current = path; const base = basename(current); // Handle funced files specially - they should be treated as part of __fish_config_dir if (LspDocument.isFuncedPath(current) || LspDocument.isCommandlineBufferPath(current)) { const specialRoot = getFuncedOrCommandlineWorkspaceRoot(); if (specialRoot) return specialRoot; // Fallback to default if __fish_config_dir is not set logger.warning('getFuncedWorkspaceRoot() returned undefined, falling back to ~/.config/fish'); return join(process.env.HOME || '/tmp', '.config', 'fish'); } if (current.startsWith('/tmp')) { return current; } // check if the path is a fish workspace // (i.e., `~/.config/fish`, `/usr/share/fish`, `~/some_plugin`) if (SyncFileHelper.isDirectory(current) && isFishWorkspacePath(current)) { return current; } // If path is a fish directory or config.fish, return parent // Check if the parent is a fish directory or the current is config.fish // (i.e., `~/.config/fish/{functions,conf.d,completions}`, `~/.config/fish/config.fish`) if (FISH_DIRS.includes(base) || base === CONFIG_FILE) { return dirname(current); } // Walk up looking for fish workspace indicators while (current !== dirname(current)) { // Check for fish dirs in current directory for (const dir of FISH_DIRS) { if (basename(current) === dir) { return dirname(current); } } // Check for config.fish or fish dirs as children if ( FISH_DIRS.some(dir => isFishWorkspacePath(join(current, dir))) || isFishWorkspacePath(join(current, CONFIG_FILE))) { return current; } current = dirname(current); } // Check if we're in a configured path return config.fish_lsp_all_indexed_paths.find(p => path.startsWith(p)); } /** * Gets a human-readable name for the workspace root */ export function getWorkspaceName(uri: string): string { const root = getWorkspaceRootFromUri(uri); if (!root) return ''; // Special cases for system directories // if (root.endsWith('/.config/fish')) return '__fish_config_dir'; // const specialName = autoloadedFishVariableNames.find(loadedName => process.env[loadedName] === root); const specialName = env.findAutolaodedKey(root); // env.getAutoloadedKeys().forEach((key) => { // logger.log(key, env.getAsArray(key)); // }) logger.debug('getWorkspaceName', { root, specialName }); if (specialName) return specialName; // get the base of the path, if it is a fish workspace (ends in `fish`) // return the entire path name as the name of the workspace const base = basename(root); if (base === 'fish') return root; // For other paths, return the workspace root's basename return base; } /** * Checks if a path indicates a fish workspace */ export function isFishWorkspacePath(path: string): boolean { if (SyncFileHelper.isDirectory(path) && (SyncFileHelper.exists(`${path}/functions`) || SyncFileHelper.exists(`${path}/completions`) || SyncFileHelper.exists(`${path}/conf.d`) ) ) { return SyncFileHelper.isDirectory(path); } if (basename(path) === CONFIG_FILE) { return true; } return config.fish_lsp_all_indexed_paths.includes(path); } /** * Determines if a URI is within a fish workspace */ export function isInFishWorkspace(uri: string): boolean { return getWorkspaceRootFromUri(uri) !== undefined; } export function initializeEnvWorkspaces(): FishUriWorkspace[] { // if (config.fish_lsp_single_workspace_support) return []; return config.fish_lsp_all_indexed_paths .map(path => SyncFileHelper.expandEnvVars(path)) // Expand environment variables first .map(path => create(pathToUri(path))) .filter((ws): ws is FishUriWorkspace => ws !== null); } /** * Creates a FishUriWorkspace from a URI * @returns null if the URI is not in a fish workspace, otherwise the workspace */ export function create(uri: string): FishUriWorkspace | null { const uriPath = uriToPath(uri); // Handle funced files - they should be treated as part of __fish_config_dir if (LspDocument.isFuncedPath(uriPath) || LspDocument.isCommandlineBufferPath(uriPath)) { const pathType = LspDocument.isFuncedPath(uriPath) ? 'funced' : 'command-line'; const rootPath = getWorkspaceRootFromUri(uri); const workspaceName = getWorkspaceName(uri); if (!rootPath || !workspaceName) { logger.warning(`Failed to get workspace root/name for ${pathType} file`, { uri, rootPath, workspaceName }); return null; } return { name: workspaceName, uri: pathToUri(rootPath), path: rootPath, }; } // skip workspaces for tmp if (isTmpWorkspace(uri)) { return { name: uriPath, uri, path: uriPath, }; } if (!isInFishWorkspace(uri)) return null; const trimmedUri = trimFishFilePath(uri); if (!trimmedUri) return null; const rootPath = getWorkspaceRootFromUri(trimmedUri); const workspaceName = getWorkspaceName(trimmedUri); if (!rootPath || !workspaceName) return null; return { name: workspaceName, uri: pathToUri(rootPath), path: rootPath, }; } } /** * Minimal tracker for URI analysis status within a workspace */ export class UriTracker { private _indexed = new Set(); private _pending = new Set(); static create(...uris: string[]) { const tracker = new UriTracker(); for (const uri of uris) { tracker.add(uri); } return tracker; } /** * Add URIs to pending if not already indexed */ add(...uris: string[]) { for (const uri of uris) { if (!this._indexed.has(uri)) { this._pending.add(uri); } } return this; } /** * Add URIs to pending analysis */ addPending(uris: string[]) { for (const uri of uris) { if (!this._indexed.has(uri)) { this._pending.add(uri); } } return this; } /** * Mark URI as indexed (analyzed) */ markIndexed(uri: string): void { this._pending.delete(uri); this._indexed.add(uri); } /** * Mark URI as pending analysis */ markPending(uri: string): void { this._indexed.delete(uri); this._pending.add(uri); } /** * Get all URIs (both indexed and pending) */ get all(): string[] { return [...this._indexed, ...this._pending]; } allAsSet(): Set { return new Set([...this._indexed, ...this._pending]); } /** * Get all indexed URIs */ get indexed(): string[] { return Array.from(this._indexed); } /** * Get all pending URIs */ get pending(): string[] { return Array.from(this._pending); } /** * Get pending URIs count */ get pendingCount(): number { return this._pending.size; } /** * Get indexed URIs count */ get indexedCount(): number { return this._indexed.size; } /** * Check if URI is indexed */ isIndexed(uri: string): boolean { return this._indexed.has(uri); } has(uri: string): boolean { return this._indexed.has(uri) || this._pending.has(uri); } } ================================================ FILE: src/virtual-fs.ts ================================================ import * as fs from 'fs'; import path, { resolve, join } from 'path'; import { existsSync, writeFileSync, unlinkSync } from 'fs'; import { promisify } from 'util'; import { execFile, execFileSync } from 'child_process'; import { Volume } from 'memfs'; import { tmpdir } from 'os'; const execAsync = promisify(execFile); // Local imports import { config } from './config'; import { logger } from './logger'; import packageJson from '@package'; import buildTime from '@embedded_assets/build-time.json'; import manPageContent from '@embedded_assets/man/fish-lsp.1'; // Helper function to get the fish path from config // Using a function that imports config lazily to avoid circular dependencies const getFishPath = (): string => { return config.fish_lsp_fish_path; }; type FindMatchPredicateFunction = (vf: VirtualFile) => boolean; type FindMatchPredicate = string | FindMatchPredicateFunction; class VirtualFile { private filetype: 'fish' | 'wasm' | 'json' | 'man' | 'unknown' = 'unknown'; private constructor( // public realpath: string, public filepath: string, public content: string | Buffer | Promise, ) { this.filetype = filepath.endsWith('.fish') ? 'fish' : filepath.endsWith('.wasm') ? 'wasm' : filepath.endsWith('.json') ? 'json' : filepath.endsWith('.1') ? 'man' : 'unknown'; if (this.filetype === 'wasm') { if (typeof this.content === 'string') { if (this.content.startsWith('data:application/wasm;base64,')) { this.content = Buffer.from(this.content.split(',')[1]!, 'base64'); } else if (this.content.startsWith('bundled://')) { // Handle bundled WASM - content will be resolved lazily this.content = content.toString(); } else { this.content = ''; } } } } static create( filepath: string, content: string | Buffer | Promise, ) { return new VirtualFile(filepath, content); } async getContent(): Promise { if (this.filetype === 'wasm' && typeof this.content === 'string' && this.content.startsWith('bundled://')) { // Resolve bundled WASM content return Buffer.from(this.content); } return this.content as string | Buffer; } get type() { return this.filetype; } exists(): boolean { return existsSync(this.filepath); } getParentDirectory(): string { if (this.filepath.includes('/')) { const dir = path.dirname(this.filepath).trim(); if (dir === '.' || dir === '/') { return ''; } return dir; } return ''; } depth(): number { const dir = this.getParentDirectory(); if (!dir) return 0; return dir.split('/').length; } basename(): string { return path.basename(this.filepath); } insideDirectory(dir: string): boolean { const parentDir = this.getParentDirectory(); return parentDir === dir || parentDir.startsWith(dir + '/'); } } export const VirtualFiles = [ // Man VirtualFile.create('man/fish-lsp.1', manPageContent), // Build info VirtualFile.create('out/build-time.json', JSON.stringify(buildTime)), // Package info VirtualFile.create('package.json', JSON.stringify(packageJson)), ]; // Remove filter since some content is async and can't be checked here class VirtualFileSystem { private vol: Volume; private virtualMountPoint: string; private isInitialized: boolean = false; public allFiles: VirtualFile[] = [...VirtualFiles]; public directories: string[] = [...new Set(VirtualFiles.filter(vf => vf.depth() > 0).map(vf => vf.getParentDirectory()))]; constructor() { this.virtualMountPoint = join(tmpdir(), 'fish-lsp.virt'); this.vol = new Volume(); // Don't call setupVirtualFS in constructor since it's async now // It will be called during initialize() } private async setupVirtualFS() { const virtualFiles: Record = {}; // Process all files, resolving async content for (const virt of this.allFiles) { try { const content = await virt.getContent(); virtualFiles[`/${virt.filepath}`] = content; } catch (error) { logger.warning(`Failed to get content for ${virt.filepath}:`, error); // Skip files that fail to load } } // Initialize the volume with all files this.vol.fromJSON(virtualFiles, '/'); this.isInitialized = true; } /** * Initialize the virtual filesystem by writing files to the virtual mount point */ async initialize(): Promise { if (this.isInitialized) { return; } try { // First setup the virtual filesystem with async content await this.setupVirtualFS(); // Create the virtual mount point directory await fs.promises.mkdir(this.virtualMountPoint, { recursive: true }); // Write all virtual files to actual filesystem at mount point const writePromises: Promise[] = []; // Write fish files const fishFilesDir = join(this.virtualMountPoint, 'fish_files'); await fs.promises.mkdir(fishFilesDir, { recursive: true }); if (this.vol.existsSync('/fish_files')) { const fishFiles = this.vol.readdirSync('/fish_files') as string[]; for (const file of fishFiles) { const content = this.vol.readFileSync(`/fish_files/${file}`, 'utf8'); writePromises.push( fs.promises.writeFile(join(fishFilesDir, file), content), ); } } // Write man file if exists if (this.vol.existsSync('/man/fish-lsp.1')) { const manDir = join(this.virtualMountPoint, 'man'); await fs.promises.mkdir(manDir, { recursive: true }); const manContent = this.vol.readFileSync('/man/fish-lsp.1', 'utf8'); writePromises.push( fs.promises.writeFile(join(manDir, 'fish-lsp.1'), manContent), ); } if (this.vol.existsSync('/out/build-time.json')) { const outDir = join(this.virtualMountPoint, 'out'); await fs.promises.mkdir(outDir, { recursive: true }); const buildTimeContent = this.vol.readFileSync('/out/build-time.json', 'utf8'); writePromises.push( fs.promises.writeFile(join(outDir, 'build-time.json'), buildTimeContent), ); } if (this.vol.existsSync('/package.json')) { const pkgContent = this.vol.readFileSync('/package.json', 'utf8'); writePromises.push( fs.promises.writeFile(join(this.virtualMountPoint, 'package.json'), pkgContent), ); } await Promise.all(writePromises); // this.isInitialized is already set to true in setupVirtualFS() } catch (error) { logger.warning('Failed to initialize virtual filesystem:', error); } } /** * Get the path to a file in the virtual mount point */ getVirtualPath(relativePath: string): string { const found = this.allFiles.find(vf => vf.filepath.endsWith(relativePath)); if (found) { return path.join(this.virtualMountPoint, found.filepath); } throw new Error(`File not found in virtual filesystem: ${relativePath}`); } /** * Get the virtual mount point directory */ getMountPoint(): string { return this.virtualMountPoint; } /** * Check if virtual filesystem is initialized */ isReady(): boolean { return this.isInitialized; } /** * Display virtual filesystem structure like tree command */ displayTree(): string { const lines: string[] = []; // Header lines.push('', '/tmp/fish-lsp.virt/'); const fileCount = this.allFiles.length; const dirCount = this.directories.length; // Get directories and root files const sortedDirs = this.directories.filter(dir => dir && dir !== '/').sort(); const filesAtRoot = this.allFiles.filter(vf => !vf.getParentDirectory() || vf.getParentDirectory() === ''); // Create a combined list of directories and root files, sorted by name const allItems = [ ...sortedDirs.map(dir => ({ type: 'dir', name: dir })), ...filesAtRoot.map(file => ({ type: 'file', name: file.basename(), file })), ].sort((a, b) => a.name.localeCompare(b.name)); // Display items in order allItems.forEach((item, index) => { const isLast = index === allItems.length - 1; const prefix = isLast ? '└── ' : '├── '; if (item.type === 'dir') { lines.push(`${prefix}${item.name}/`); // Add files in this directory const filesInDir = this.allFiles.filter(vf => vf.getParentDirectory() === item.name); filesInDir.forEach((vf, fileIndex) => { const isLastFile = fileIndex === filesInDir.length - 1; const filePrefix = isLast ? isLastFile ? ' └── ' : ' ├── ' : isLastFile ? '│ └── ' : '│ ├── '; lines.push(`${filePrefix}${vf.basename()}`); }); } else { lines.push(`${prefix}${item.name}`); } }); // Add summary lines.push(''); if (dirCount > 0 && fileCount > 0) { lines.push(`${dirCount} directories, ${fileCount} files`); } else if (dirCount > 0) { lines.push(`${dirCount} directories`); } else if (fileCount > 0) { lines.push(`${fileCount} files`); } return lines.join('\n'); } /** * Cleanup virtual filesystem */ async cleanup(): Promise { try { await fs.promises.rm(this.virtualMountPoint, { recursive: true, force: true }); this.isInitialized = false; } catch (error) { logger.warning('Failed to cleanup virtual filesystem:', error); } } find(predicate: FindMatchPredicate): VirtualFile | undefined { if (typeof predicate === 'string') { return this.allFiles.find(vf => vf.filepath.endsWith(predicate)); } return this.allFiles.find(predicate); } get fishFiles() { return this.allFiles.filter(vf => vf.filepath.startsWith('fish_files/')) .map(vf => ({ file: `/${vf.filepath}`, content: vf.content.toString(), exec: (...args: string[]) => { // Write content to temp file for execution const tempPath = path.join(tmpdir(), path.basename(vf.filepath)); writeFileSync(tempPath, vf.content.toString()); try { const result = execFileSync(getFishPath(), [tempPath, ...args])?.toString().trim() || ''; unlinkSync(tempPath); // Clean up temp file return result; } catch (error) { unlinkSync(tempPath); // Clean up temp file on error throw error; } }, execAsync: async (...args: string[]) => { // Write content to temp file for execution const tempPath = path.join(tmpdir(), path.basename(vf.filepath)); writeFileSync(tempPath, vf.content.toString()); try { const result = await execAsync(getFishPath(), [tempPath, ...args]); unlinkSync(tempPath); // Clean up temp file return result; } catch (error) { unlinkSync(tempPath); // Clean up temp file on error throw error; } }, })); } /** * Get the best available path for a file - VFS path if bundled, or development paths */ getPathOrFallback(vfsRelativePath: string, ...fallbackPaths: string[]): string { // Try VFS first (for bundled environment) try { const virtualPath = this.getVirtualPath(vfsRelativePath); if (existsSync(virtualPath)) { return virtualPath; } if (virtualPath && virtualPath.endsWith(vfsRelativePath)) { return virtualPath; } } catch { // VFS path not available } // Try fallback paths (for development environment) for (const path of fallbackPaths) { if (existsSync(path) && fs.statSync(path).isFile()) { return path; } } // Return first fallback as default return fallbackPaths[0] || vfsRelativePath; } } // Create singleton instance export const vfs = new VirtualFileSystem(); // Auto-initialize when we detect we're in bundled mode // (when fish_files directory doesn't exist or BUNDLED env var is set) if (process.env.FISH_LSP_BUNDLED || !fs.existsSync(resolve(process.cwd(), 'fish_files'))) { // Initialize asynchronously but don't block module loading vfs.initialize().catch(error => { logger.warning('Failed to initialize virtual filesystem:', error); }); // Clean up on exit process.on('exit', () => { // Synchronous cleanup since we can't use async in exit handler try { fs.rmSync(vfs.getMountPoint(), { recursive: true, force: true }); } catch (error) { // Ignore cleanup errors on exit } }); } export default vfs; ================================================ FILE: src/web.ts ================================================ // Import polyfills for browser/Node.js compatibility import './utils/polyfills'; import { createConnection, BrowserMessageReader, BrowserMessageWriter } from 'vscode-languageserver/browser'; // TODO: // Web-compatible version of fish-lsp // This is a simplified version that aims to get base version working in browser environments export class FishLspWeb { private connection: ReturnType; constructor() { // Create browser-compatible connection this.connection = createConnection(new BrowserMessageReader(self), new BrowserMessageWriter(self)); this.setupHandlers(); } private setupHandlers() { this.connection.onInitialize((params) => { this.connection.console.log(`Fish LSP Web initializing...\n{ ${params}}`); return { capabilities: { textDocumentSync: 1, // Full sync completionProvider: { resolveProvider: true, triggerCharacters: ['$', '-', ' '], }, hoverProvider: true, documentSymbolProvider: true, // Add more capabilities as needed for web version }, serverInfo: { name: 'fish-lsp-web', version: '1.0.0', }, }; }); this.connection.onCompletion(() => { // Basic completion implementation for web return { isIncomplete: false, items: [ { label: 'echo', kind: 3, // Function detail: 'Print arguments to stdout', }, { label: 'set', kind: 3, detail: 'Set or get environment variables', }, ], }; }); this.connection.onHover(() => { return { contents: 'Fish LSP Web - Limited functionality in browser', }; }); // Handle browser-specific cleanup if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { this.connection.dispose(); }); } } public listen() { this.connection.listen(); } public dispose() { this.connection.dispose(); } } // Auto-start for web environments if (typeof window !== 'undefined' || typeof self !== 'undefined') { const fishLsp = new FishLspWeb(); fishLsp.listen(); } export default FishLspWeb; ================================================ FILE: tests/alias-conversion.test.ts ================================================ import { Diagnostic } from 'vscode-languageserver'; import { initializeParser } from '../src/parser'; import { createAliasInlineAction } from '../src/code-actions/alias-wrapper'; import { ErrorCodes } from '../src/diagnostics/error-codes'; import { LspDocument } from '../src/document'; import * as Parser from 'web-tree-sitter'; import { setLogger, fail } from './helpers'; import { isCommandWithName } from '../src/utils/node-types'; import { getChildNodes } from '../src/utils/tree-sitter'; import { execAsyncF } from '../src/utils/exec'; setLogger(); describe('Alias to Function Conversion', () => { setLogger(); let parser: Parser; beforeAll(async () => { parser = await initializeParser(); }); function createTestDocument(content: string): LspDocument { return { uri: 'file:///test/test.fish', getText: () => content, languageId: 'fish', version: 1, } as LspDocument; } function createDiagnostic(line: number, character: number, length: number): Diagnostic { return { range: { start: { line, character }, end: { line, character: character + length }, }, message: 'alias used, prefer using functions instead', code: ErrorCodes.usedWrapperFunction, severity: 2, source: 'fish-lsp', }; } const testCases = [ { name: 'basic alias with equals', input: 'alias ll=\'ls -l\'', expected: `function ll --wraps 'ls -l' --description "alias ll=ls -l" ls -l $argv end`, }, { name: 'basic alias with space', input: 'alias ll \'ls -l\'', expected: `function ll --wraps 'ls -l' --description "alias ll 'ls -l'" ls -l $argv end`, }, { name: 'alias requiring builtin prefix', input: 'alias echo=\'echo -n\'', expected: `function echo --wraps 'echo -n' --description "alias echo=echo -n" builtin echo -n $argv end`, }, { name: 'alias requiring command prefix', input: 'alias ls=\'ls -la\'', expected: `function ls --wraps 'ls -la' --description "alias ls=ls -la" command ls -la $argv end`, }, { name: 'alias that should skip wraps due to recursion', input: 'alias foo=\'foo bar\'', expected: `function foo --description "alias foo=foo bar" command foo bar $argv end`, }, { name: 'alias with quotes in command', input: 'alias greet=\'echo "hello world"\'', expected: `function greet --wraps 'echo "hello world"' --description "alias greet=echo \\"hello world\\"" echo "hello world" $argv end`, }, { name: 'alias with sudo as last word', input: 'alias mysudo=\'command sudo\'', expected: `function mysudo --wraps 'command sudo' --description "alias mysudo=command sudo" command sudo $argv end`, }, ]; it('test execAsyncFish', async () => { const out = await execAsyncF('alias ls="ls -l" && functions ls | tail +2 | fish_indent'); console.log({ out }); const out2 = await execAsyncF('alias ls=\'ls -l\' && functions ls | tail +2 | fish_indent'); console.log({ out2 }); expect(out).toBeTruthy(); expect(out2).toBeTruthy(); }); testCases.forEach(({ name, input, expected }) => { it(name, async () => { const doc = createTestDocument(input); const tree = parser.parse(input); const diagnostic = createDiagnostic(0, 0, input.length); const aliasNode = getChildNodes(tree.rootNode).find(node => isCommandWithName(node, 'alias')); if (!aliasNode) fail(); console.log({ text: aliasNode?.text }); const action = await createAliasInlineAction(doc, aliasNode!); console.log(JSON.stringify(action, null, 2)); expect(action).toBeTruthy(); }); }); it('returns null for non-alias diagnostics', async () => { const doc = createTestDocument('alias ll=\'ls -l\''); const tree = parser.parse(doc.getText()); const diagnostic = { ...createDiagnostic(0, 0, doc.getText().length), code: 9999, // Different error code }; expect(diagnostic).toBeTruthy(); const aliasNode = getChildNodes(tree.rootNode).find(node => isCommandWithName(node, 'alias'))!; const action = await createAliasInlineAction(doc, aliasNode); expect(action).toBeTruthy(); }); it('returns null for invalid alias syntax', async () => { const doc = createTestDocument('alias'); const tree = parser.parse(doc.getText()); const diagnostic = createDiagnostic(0, 0, doc.getText().length); expect(diagnostic).toBeTruthy(); const action = await createAliasInlineAction(doc, tree.rootNode); expect(action).toBeUndefined(); expect(!action).toBeTruthy(); }); }); ================================================ FILE: tests/analyze-functions.test.ts ================================================ import * as Parser from 'web-tree-sitter'; import { getChildNodes, getRange } from '../src/utils/tree-sitter'; import { isCommandWithName } from '../src/utils/node-types'; import { setLogger } from './helpers'; import { initializeParser } from '../src/parser'; import { TextDocumentItem } from 'vscode-languageserver'; import { LspDocument } from '../src/document'; import { Analyzer } from '../src/analyze'; // import { getGlobalSymbols } from '../src/parsing/symbol'; // import { filterGlobalSymbols } from '../src/document-symbol'; let parser: Parser; let analyzer: Analyzer; describe('Analyze functions in conf.d', () => { setLogger(); beforeAll(async () => { parser = await initializeParser(); analyzer = new Analyzer(parser); }); beforeEach(async () => { parser.reset(); }); const tests = [ { name: 'simple function', input: ` function foo echo foo end`, uri: 'file:///home/user/.config/fish/conf.d/foo.fish', }, { name: 'functions/bar.fish', input: ` function bar echo 'bar' end`, uri: 'file:///home/user/.config/fish/functions/bar.fish', }, { name: 'function with other function', input: ` function foo foo_1 foo_2 foo_3 end function foo_1 echo foo_1 end function foo_2 echo foo_2 end function foo_3 echo foo_3 end`, uri: 'file:///home/user/.config/fish/conf.d/__foo.fish', }, { name: 'function /tmp/foo.fish', input: ` function foo echo foo end foo`, uri: 'file:///tmp/foo.fish', }, ]; tests.forEach(({ name, input, uri }) => { if (name !== 'function /tmp/foo.fish') return; it(name, () => { console.log('-'.repeat(80)); console.log(name); console.log('='.repeat(80)); const tree = parser.parse(input); const rootNode = tree.rootNode; const textDocument = TextDocumentItem.create(uri, 'fish', 1, input); const doc = new LspDocument(textDocument); analyzer.analyze(doc); console.log('rootNode', rootNode.text); const symbols = analyzer.getFlatDocumentSymbols(doc.uri); const globalSymbols = symbols.filter(s => s.isGlobal()); const ws = analyzer.getWorkspaceSymbols('foo_1'); let position = { line: 0, character: 0 }; for (const node of getChildNodes(rootNode)) { if (isCommandWithName(node, 'foo')) { position = getRange(node).end; break; } } const definition = analyzer.getDefinition(doc, position); console.log('definition', definition); console.log('position', position); console.log('symbols', symbols.map(s => { return { name: s.name, scope: s.scope.scopeTag, }; })); console.log('globalsymbols', globalSymbols.map(s => s.name)); console.log('workspace_symbols', ws.map(s => s.name)); console.log('-'.repeat(80)); // const functions = nodes.filter(isTopLevelFunctionDefinition); // expect(functions.length).toBeGreaterThan(0); }); }); }); ================================================ FILE: tests/analyzer.test.ts ================================================ import { setLogger, createFakeLspDocument } from './helpers'; import { initializeParser } from '../src/parser'; /* @ts-ignore */ import Parser, { SyntaxNode } from 'web-tree-sitter'; import { analyzer, Analyzer } from '../src/analyze'; import { getChildNodes } from '../src/utils/tree-sitter'; import { isFunctionDefinitionName } from '../src/utils/node-types'; import * as LSP from 'vscode-languageserver'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; /* @ts-ignore */ import os from 'os'; import { join } from 'path'; import { pathToUri } from '../src/utils/translation'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; let parser: Parser; const tmpDir = join(os.tmpdir(), 'fish-lsp-analyzer-tests'); describe('Analyzer class in file: `src/analyze.ts`', () => { setLogger(); beforeEach(async () => { parser = await initializeParser(); await Analyzer.initialize(); await setupProcessEnvExecFile(); }); describe('analyze', () => { it('default', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', ' return 1', 'end', ].join('\n')); const result = analyzer.analyze(document); expect(result).toBeDefined(); expect(result.documentSymbols).toHaveLength(1); }); it('multiple functions', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', ' return 1', 'end', 'function bar', ' return 2', 'end', ].join('\n')); const result = analyzer.analyze(document); expect(result).toBeDefined(); expect(result.documentSymbols).toHaveLength(2); }); it('function with args', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo -a arg1 -a arg2', ' return 1', 'end', ].join('\n')); const result = analyzer.analyze(document); expect(result).toBeDefined(); expect(result.documentSymbols).toHaveLength(1); }); }); describe('findDocumentSymbol()', () => { it('function name', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', ' return 1', 'end', ].join('\n')); analyzer.analyze(document); const { rootNode } = parser.parse(document.getText()); const child: SyntaxNode = getChildNodes(rootNode).find(n => isFunctionDefinitionName(n))!; const position: LSP.Position = document.positionAt(child.startIndex); const result = analyzer.findDocumentSymbol(document, position); expect(result).toBeDefined(); expect(result?.name).toEqual('foo'); expect(result?.kind).toEqual(LSP.SymbolKind.Function); }); }); describe('findDocumentSymbols()', () => { it('function name', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', ' return 1', 'end', 'function bar', ' return 2', 'end', ].join('\n')); analyzer.analyze(document); const { rootNode } = parser.parse(document.getText()); const child: SyntaxNode = getChildNodes(rootNode).find(n => isFunctionDefinitionName(n))!; const position: LSP.Position = document.positionAt(child.startIndex); const result = analyzer.findDocumentSymbol(document, position); expect(result).toBeDefined(); expect(result?.name).toEqual('foo'); expect(result?.kind).toEqual(LSP.SymbolKind.Function); }); }); describe('getTree', () => { it('function name', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', ' return 1', 'end', ].join('\n')); analyzer.analyze(document); const matchTree = parser.parse(document.getText()); const result = analyzer.getTree(document.uri); expect(result).toBeDefined(); expect(result!.rootNode.text).toEqual(matchTree.rootNode.text); }); }); describe('getRootNode', () => { it('function name', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', ' return 1', 'end', ].join('\n')); analyzer.analyze(document); const output = parser.parse(document.getText()).rootNode; const result = analyzer.getRootNode(document.uri); expect(result).toBeDefined(); expect(result!.text).toEqual(output.text); }); }); describe('getDocument', () => { it('simple', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', 'end', ].join('\n')); analyzer.analyze(document); const result = analyzer.getDocument(document.uri); expect(result).toBeDefined(); expect(result).toEqual(document); }); }); describe('getFlatDocumentSymbols', () => { it('simple', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', 'end', ].join('\n')); analyzer.analyze(document); const result = analyzer.getFlatDocumentSymbols(document.uri); expect(result).toBeDefined(); expect(result).toHaveLength(2); }); it('multiple functions', () => { const document = createFakeLspDocument('functions/foo.fish', [ 'function foo', 'end', 'function bar', 'end', ].join('\n')); analyzer.analyze(document); const result = analyzer.getFlatDocumentSymbols(document.uri); expect(result).toBeDefined(); expect(result).toHaveLength(4); }); it('completion', () => { const document = createFakeLspDocument('completions/foo.fish', [ 'function __foo_helper', 'end', 'complete -c foo -f', 'complete -c foo -s h -l help -d "Display help message"', 'complete -c foo -s v -l version -d "Display version information"', ].join('\n')); analyzer.analyze(document); const result = analyzer.getFlatDocumentSymbols(document.uri); expect(result).toBeDefined(); expect(result).toHaveLength(2); }); it('config', () => { const document = createFakeLspDocument('config.fish', [ 'set -g foo bar', 'set -g bar foo', ].join('\n')); analyzer.analyze(document); const result = analyzer.getFlatDocumentSymbols(document.uri); expect(result).toBeDefined(); expect(result).toHaveLength(2); }); }); describe('analyzePath()', () => { let testFilePath: string; // Before all tests run beforeAll(async () => { // Make sure temp directory exists if (!existsSync(tmpDir)) { mkdirSync(tmpDir, { recursive: true }); } // Initialize parser for analyzer parser = await initializeParser(); await setupProcessEnvExecFile(); }); // After all tests run afterAll(() => { // Clean up the temp directory and all its contents if (existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } }); // Before each test beforeEach(() => { // Ensure test directory exists if (!existsSync(tmpDir)) { mkdirSync(tmpDir, { recursive: true }); } }); // After each test afterEach(() => { // Clean up test file after each test if (existsSync(testFilePath)) { rmSync(testFilePath, { force: true }); } }); it('simple', async () => { testFilePath = join(tmpDir, 'foo.fish'); const content = [ 'function foo', 'end', ].join('\n'); writeFileSync(testFilePath, content); const result = analyzer.analyzePath(testFilePath); expect(result).toBeDefined(); expect(result?.documentSymbols).toHaveLength(2); }); it('multiple functions', async () => { testFilePath = join(tmpDir, 'baz.fish'); const content = [ 'function foo', 'end', 'function bar', 'end', 'function baz', ' foo', ' bar', 'end', ].join('\n'); writeFileSync(testFilePath, content); const result = analyzer.analyzePath(testFilePath); expect(result).toBeDefined(); expect(result?.documentSymbols).toHaveLength(4); const lookupUri = pathToUri(testFilePath); const document = analyzer.getDocument(lookupUri); expect(document).toBeDefined(); expect(document?.uri).toEqual(lookupUri); const flatSymbols = analyzer.getFlatDocumentSymbols(lookupUri); expect(flatSymbols).toBeDefined(); expect(flatSymbols).toHaveLength(7); expect(flatSymbols.map(s => s.name)).toEqual(['argv', 'foo', 'bar', 'baz', 'argv', 'argv', 'argv']); }); }); // TODO: test more Analyzer methods }); ================================================ FILE: tests/cli.test.ts ================================================ import { accumulateStartupOptions } from '../src/utils/commander-cli-subcommands'; import { validHandlers } from '../src/config'; import { timeServerStartup } from '../src/utils/startup'; import { performHealthCheck } from '../src/utils/health-check'; import { buildFishLspCompletions } from '../src/utils/get-lsp-completions'; import { commandBin } from '../src/cli'; import { vi } from 'vitest'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { Analyzer } from '../src/analyze'; import vfs from '../src/virtual-fs'; import { promisify } from 'util'; import { exec, spawn } from 'child_process'; import { SyncFileHelper } from '../src/utils/file-operations'; import { fail } from 'assert'; const execAsync = promisify(exec); describe('cli tests', () => { // Storage for captured output let capturedOutput: string[] = []; let originalStdoutWrite: typeof process.stdout.write; let originalStderrWrite: typeof process.stderr.write; // Clean wrapper function for running fish-lsp commands const runFishLspCommand = async (args: string[], options: { timeout?: number; allowNonZeroExit?: boolean; expectedExitCodes?: number[]; } = {}): Promise<{ stdout: string; stderr: string; exitCode: number; output: string; }> => { const { timeout = 15000, allowNonZeroExit = false, expectedExitCodes = [0], } = options; const p = spawn('./dist/fish-lsp', [...args], { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd(), timeout: timeout, }); let stdout = ''; let stderr = ''; // Set up data collection p.stdout?.on('data', (data) => { stdout += data.toString(); }); p.stderr?.on('data', (data) => { stderr += data.toString(); }); // Create promise that resolves when process completes const result = await new Promise<{ exitCode: number; stdout: string; stderr: string; }>((resolve, reject) => { const timeoutId = setTimeout(() => { p.kill(); reject(new Error(`Command timed out after ${timeout}ms`)); }, timeout); p.on('error', (error: any) => { clearTimeout(timeoutId); reject(new Error(`Process error: ${error.message}`)); }); p.on('close', (exitCode) => { clearTimeout(timeoutId); resolve({ exitCode: exitCode || 0, stdout, stderr, }); }); }); const output = result.stdout + result.stderr; const isValidExitCode = allowNonZeroExit || expectedExitCodes.includes(result.exitCode); if (!isValidExitCode) { throw new Error(`Command failed with exit code ${result.exitCode}: ${result.stderr}`); } return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, output, }; }; beforeAll(async () => { await vfs.initialize(); await setupProcessEnvExecFile(); await Analyzer.initialize(); if (!SyncFileHelper.exists('./dist/fish-lsp')) { try { await execAsync('yarn run build:npm'); } catch (error) { console.error('(FAILED TO BUILD): "./dist/fish-lsp" (`yarn run build:npm`: npm binary|bin w/ node_modules) before tests:', error); console.log('NO EXISTING ./dist/fish-lsp binary found, cannot continue tests.'); fail(); } } }); // Setup and teardown beforeEach(() => { // Clear previous output before each test capturedOutput = []; // Mock stdout and stderr to capture logger output originalStdoutWrite = process.stdout.write; originalStderrWrite = process.stderr.write; process.stdout.write = vi.fn((str: string) => { capturedOutput.push(str); return true; }) as any; process.stderr.write = vi.fn((str: string) => { capturedOutput.push(str); return true; }) as any; }); afterEach(() => { // Restore original functions process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; }); describe('start test', () => { describe('accumulate startup options', () => { it('fish-lsp start --enable completion \\\n\t\t--disable hover \\\n\t\t--enable diagnostics inlayHint', () => { const args = [ 'start', '--enable', 'completion', '--disable', 'hover', '--enable', 'diagnostics', 'inlayHint', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual(['completion', 'diagnostics', 'inlayHint']); expect(disabled).toEqual(['hover']); expect(dumpCmd).toEqual(false); }); it('fish-lsp start --dump', () => { const args = [ 'start', '--dump', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([]); expect(disabled).toEqual([]); expect(dumpCmd).toEqual(true); }); it('fish-lsp start --disable ALL_HANDLERS', () => { const args = [ 'start', '--disable', ...validHandlers, ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([]); expect(disabled).toEqual([...validHandlers]); expect(dumpCmd).toEqual(false); }); it('fish-lsp start \\\n\t\t--disable hover inlayHint completion executeCommand \\\n\t\t--stdio', () => { const args = [ 'start', '--disable', 'hover', 'inlayHint', 'completion', 'executeCommand', '--stdio', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([]); expect(disabled).toEqual(['hover', 'inlayHint', 'completion', 'executeCommand']); expect(dumpCmd).toEqual(false); }); it('fish-lsp start --enable ALL_HANDLERS \\\n\t\t--socket 2001 \\\n\t\t--disable hover', () => { const args = [ 'start', '--enable', ...validHandlers, '--socket', '2001', '--disable', 'hover', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([...validHandlers]); expect(disabled).toEqual(['hover']); expect(dumpCmd).toEqual(false); }); it('fish-lsp start --port 3000 \\\n\t\t--disable hover', () => { const args = [ 'start', '--port', '3000', '--disable', 'hover', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([]); expect(disabled).toEqual(['hover']); expect(dumpCmd).toEqual(false); }); it('fish-lsp start --enable --disable logging complete codeAction', () => { const args = [ 'start', '--enable', '--disable', 'logging', 'complete', 'codeAction', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([]); expect(disabled).toEqual(['logging', 'complete', 'codeAction']); expect(dumpCmd).toEqual(false); }); it('fish-lsp start --enable ALL_HANDLERS --disable ALL_HANDLERS --dump', () => { const args = [ 'start', '--enable', ...validHandlers, '--disable', ...validHandlers, '--dump', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([...validHandlers]); expect(disabled).toEqual([...validHandlers]); expect(dumpCmd).toEqual(true); }); it('fish-lsp start --enable ALL_HANDLERS --help --dump', () => { const args = [ 'start', '--enable', ...validHandlers, '--help', '--dump', ]; const { enabled, disabled, dumpCmd } = accumulateStartupOptions(args); expect(enabled).toEqual([...validHandlers]); expect(disabled).toEqual([]); expect(dumpCmd).toEqual(true); }); }); }); describe('info', () => { it('fish-lsp info --time-startup', async () => { await timeServerStartup({ timeOnly: true, }); expect(capturedOutput.length).toBeGreaterThan(0); // Check that we captured some timing output const outputText = capturedOutput.join(''); expect(outputText).toContain('Server Start Time'); expect(outputText).toContain('ms'); expect(outputText.length).toBeGreaterThan(0); }); it.skip('fish-lsp info --check-health', async () => { await performHealthCheck(); expect(capturedOutput.length).toBeGreaterThan(0); // Check that we captured some health check output const outputText = capturedOutput.join(''); expect(outputText.length).toBeGreaterThan(0); }); // 10 second timeout }); describe('help', () => { it('fish-lsp --help', async () => { const { output } = await runFishLspCommand(['--help'], { expectedExitCodes: [0, 1], // Help commands often exit with 0 or 1 }); // Debug: log the actual output to see what we get console.log('Help output (first 200 chars):', JSON.stringify(output.substring(0, 200))); // Check that we got some help output expect(output.length).toBeGreaterThan(0); expect(output).toContain('fish-lsp'); // More flexible check - see if it contains common help patterns expect(output).toMatch(/usage|help|command|option/i); }); // 15 second timeout for the test }); describe('env', () => { it('fish-lsp env --names', async () => { const { output } = await runFishLspCommand(['env', '--names'], { allowNonZeroExit: true, // Allow command to fail due to fish file compilation }); expect(output.length).toBeGreaterThan(0); // Since the command fails due to fish compilation, just verify we got output // In a real environment, this would contain the expected environment variables expect(output).toMatch(/(fish_lsp_enabled_handlers|SyntaxError.*collect)/); // The test verifies the wrapper function works, even if the command fails }); it('fish-lsp env --names --joined', async () => { const { output } = await runFishLspCommand(['env', '--names', '--joined'], { // timeout: 10000, allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); expect(output).toContain('fish_lsp_enabled_handlers'); // Should be on a single line when using --joined const lines = output.trim().split('\n'); expect(lines.length).toBe(1); expect(lines[0]).toContain('fish_lsp_enabled_handlers'); expect(lines[0]).toContain('fish_lsp_disabled_handlers'); }); it('fish-lsp env --show-default', async () => { const { output } = await runFishLspCommand(['env', '--show-default'], { timeout: 10000, allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); expect(output).toContain('set -gx fish_lsp_enabled_handlers'); expect(output).toContain('set -gx fish_lsp_disabled_handlers'); expect(output).toContain('# $fish_lsp_enabled_handlers'); expect(output).toContain('# Enables the fish-lsp handlers'); // Check for some expected default values expect(output).toContain('set -gx fish_lsp_max_background_files 10000'); expect(output).toContain('set -gx fish_lsp_enable_experimental_diagnostics false'); }); it('fish-lsp env --show-default --no-comments', async () => { const { output } = await runFishLspCommand(['env', '--show-default', '--no-comments'], { // timeout: 10000, allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); expect(output).toContain('set -gx fish_lsp_enabled_handlers'); // Should not contain comments when using --no-comments expect(output).not.toContain('#'); }); it('fish-lsp env --show-default --only fish_lsp_log_file,fish_lsp_log_level', async () => { const { output } = await runFishLspCommand(['env', '--show-default', '--only', 'fish_lsp_log_file,fish_lsp_log_level'], { allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); expect(output).toContain('set -gx fish_lsp_log_file'); expect(output).toContain('set -gx fish_lsp_log_level'); // Should not contain other variables when using --only expect(output).not.toContain('fish_lsp_enabled_handlers'); expect(output).not.toContain('fish_lsp_max_background_files'); }); it('fish-lsp env --show-default --no-global', async () => { const { output } = await runFishLspCommand(['env', '--show-default', '--no-global'], { allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); // Should use 'set -lx' instead of 'set -gx' when using --no-global expect(output).toContain('set -lx fish_lsp_enabled_handlers'); expect(output).not.toContain('set -gx fish_lsp_enabled_handlers'); }); it('fish-lsp env --show-default --no-export', async () => { const { output } = await runFishLspCommand(['env', '--show-default', '--no-export'], { allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); // Should use 'set -g' instead of 'set -gx' when using --no-export expect(output).toContain('set -g fish_lsp_enabled_handlers'); expect(output).not.toContain('set -gx fish_lsp_enabled_handlers'); }); it('fish-lsp env --create', async () => { const { output } = await runFishLspCommand(['env', '--create'], { timeout: 10000, allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); expect(output).toContain('set -gx fish_lsp_enabled_handlers'); // --create should show current/default values for environment setup expect(output).toContain('fish_lsp'); }); it('fish-lsp env help', async () => { const { output } = await runFishLspCommand(['env', '--help'], { allowNonZeroExit: true, }); expect(output.length).toBeGreaterThan(0); expect(output).toContain('generate fish-lsp env variables'); expect(output).toContain('--names'); expect(output).toContain('--show-default'); expect(output).toContain('--only'); }); }); describe('complete', () => { it('fish-lsp complete should generate valid fish syntax', async () => { // Generate the completions const completions = buildFishLspCompletions(commandBin); expect(completions).toBeDefined(); expect(typeof completions).toBe('string'); expect(completions.length).toBeGreaterThan(0); // Basic syntax checks expect(completions).toContain('complete -c fish-lsp'); expect(completions).toContain('function __fish_lsp'); }); it('fish should parse fish-lsp completions without errors', async () => { // Generate the completions const completions = buildFishLspCompletions(commandBin); // Check that the completions contain our new --dump-parse-tree flag expect(completions).toContain('--dump-parse-tree'); expect(completions).toContain('dump the tree-sitter parse tree of a file'); return new Promise((resolve, reject) => { try { // Test that fish can parse the completions without syntax errors const fishProcess = spawn('fish', ['-n'], { stdio: ['pipe', 'pipe', 'pipe'] }); let stderr = ''; fishProcess.stderr.on('data', (data) => { stderr += data.toString(); }); fishProcess.on('error', (error: any) => { if (error.code === 'ENOENT') { console.warn('Fish shell not available, skipping syntax validation test'); resolve(); return; } reject(new Error(`Fish process error: ${error.message}`)); }); fishProcess.on('close', (code) => { if (code !== 0) { reject(new Error(`Fish parsing failed with exit code ${code}: ${stderr}`)); return; } // Fish should not output any syntax errors when parsing with -n flag if (stderr.trim() !== '') { reject(new Error(`Fish parsing produced errors: ${stderr}`)); return; } resolve(); }); // Send the completions to fish fishProcess.stdin.write(completions); fishProcess.stdin.end(); // Set a timeout setTimeout(() => { fishProcess.kill(); reject(new Error('Fish parsing test timed out')); }, 5000); } catch (error: any) { reject(new Error(`Test setup failed: ${error.message}`)); } }); }); // 10 second timeout for the test }); }, 60000); // 60 second timeout for the entire suite) ================================================ FILE: tests/code-action.test.ts ================================================ import * as os from 'os'; import * as Parser from 'web-tree-sitter'; import { containsRange, findEnclosingScope, getChildNodes, getRange } from '../src/utils/tree-sitter'; import { isCommandName, isCommandWithName, isComment, isFunctionDefinitionName, isIfStatement, isMatchingOption, isOption, isString, isTopLevelFunctionDefinition } from '../src/utils/node-types'; import { Option } from '../src/parsing/options'; import { convertIfToCombinersString } from '../src/code-actions/combiner'; import { setLogger, fail, createMockConnection, setupStartupMock } from './helpers'; import { initializeParser } from '../src/parser'; import { findReturnNodes, getReturnStatusValue } from '../src/inlay-hints'; import { DidDeleteFilesNotification, TextDocumentItem } from 'vscode-languageserver'; import { documents, LspDocument } from '../src/document'; import { SyntaxNode } from 'web-tree-sitter'; import { isReservedKeyword } from '../src/utils/builtins'; import { isAutoloadedUriLoadsFunctionName, shouldHaveAutoloadedFunction } from '../src/utils/translation'; import { CompleteFlag, findFlagsToComplete, buildCompleteString } from '../src/code-actions/argparse-completions'; import { Analyzer, analyzer } from '../src/analyze'; import TestWorkspace, { TestFile } from './test-workspace-utils'; import { codeActionHandlers } from '../src/code-actions/code-action-handler'; import { testOpenDocument } from './document-test-helpers'; import FishServer, { currentDocument } from '../src/server'; import { connection } from '../src/utils/startup'; import { logger } from '../src/logger'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { createConnection } from 'net'; import { Workspace } from '../src/utils/workspace'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; let parser: Parser; describe('Code Action Tests', () => { setLogger(); beforeAll(async () => { parser = await initializeParser(); }); beforeEach(async () => { parser.reset(); }); describe('Refactor Combiner Tests', () => { const tests = [ { name: 'Convert Refactor `if`', input: ` if test -f file echo "file exists" end`, expected: `test -f file and echo "file exists"`, }, { name: 'Convert Refactor `if` with `else`', input: ` if test -f file echo "file exists" else echo "file does not exist" # comment echo 'exiting' end`, expected: `test -f file and echo "file exists" or echo "file does not exist" # comment and echo 'exiting'`, }, { name: 'Convert Refactor `if` with `else if`', input: ` if test -f file echo "file exists" else if test -d file echo "file is a directory" &> /dev/null end`, expected: `test -f file and echo "file exists" or test -d file and echo "file is a directory" &> /dev/null`, }, { name: 'Convert Refactor `if` with `else if` and `else`', input: ` if not test -f file || test -e file echo "file exists" else if test -d file echo "file is a directory" else echo "file does not exist" end`, expected: `not test -f file || test -e file and echo "file exists" or test -d file and echo "file is a directory" or echo "file does not exist"`, }, { name: 'Convert Refactor negated `if` with `else if` and `else`', input: `if ! test -e file && ! test -f file # comment blah blah echo "file is not executable" else if not test -f file echo "file exists" else if ! test -d file echo "file is a directory" else echo "file does not exist" end`, expected: `! test -e file && ! test -f file # comment blah blah and echo "file is not executable" or not test -f file and echo "file exists" or ! test -d file and echo "file is a directory" or echo "file does not exist"`, }, ]; tests.forEach(({ name, input, expected }) => { it.skip(name, async () => { const tree = parser.parse(input); const root = tree.rootNode; const node = getChildNodes(root).find(n => isIfStatement(n)); if (!node) fail(); const combiner = convertIfToCombinersString(node!); expect(combiner).toBe(expected); }); }); }); describe('Refactor Function Tests', () => { it.skip('Convert Refactor Function', async () => { const input = 'return 2'; const rootNode = parser.parse(input).rootNode; const ret = findReturnNodes(rootNode).pop(); if (!ret) fail(); expect(ret!.text).toEqual('return 2'); expect(getReturnStatusValue(ret!)).toEqual({ inlineValue: 'Misuse of shell builtins', tooltip: { code: '2', description: 'Misuse of shell builtins' }, }); }); }); describe('Refactor Function Tests', () => { describe('autoloaded tests', async () => { const tests = [ { name: 'is autoloaded function without errors', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function util --description 'autoloaded file' echo "autoloaded file" end`, expected: { autoloadType: 'functions', isMissingAutoloadedFunction: false, isMissingAutoloadedFunctionButContainsOtherFunctions: false, reservedFunctionNames: [], }, }, { name: 'autoloaded function does not have a function definition for its filename', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function not_util --description 'autoloaded file' function util --description "nested function which shouldn't count" echo 'function shadowing with the same name is not relevant' end echo "autoloaded file" end`, expected: { autoloadType: 'functions', isMissingAutoloadedFunction: true, isMissingAutoloadedFunctionButContainsOtherFunctions: true, reservedFunctionNames: [], }, }, { name: 'autoloaded function with errors', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: '', expected: { autoloadType: 'functions', isMissingAutoloadedFunction: true, isMissingAutoloadedFunctionButContainsOtherFunctions: false, reservedFunctionNames: [], }, }, { name: 'not autoloaded function without errors', uri: `file://${os.homedir()}/.config/fish/completions/no_functions.fish`, input: '', expected: { autoloadType: 'completions', isMissingAutoloadedFunction: false, isMissingAutoloadedFunctionButContainsOtherFunctions: false, reservedFunctionNames: [], }, }, { name: 'autoloaded function with naming errors', uri: `file://${os.homedir()}/.config/fish/config.fish`, input: ` function set --description 'set function is a builtin' set $argv end function command --description 'command function is a builtin' command $argv end function function --description 'function function is a builtin' echo 'function' $argv end function valid_name --description 'valid name' function break --description 'break is a builtin' echo 'invalid name' end end`, expected: { autoloadType: 'config', isMissingAutoloadedFunction: false, isMissingAutoloadedFunctionButContainsOtherFunctions: false, reservedFunctionNames: ['set', 'command', 'function', 'break'], }, }, ]; tests.forEach(async ({ name, uri, input, expected }) => { await it.skip(name, async () => { const tree = parser.parse(input); const root = tree.rootNode; const doc = new LspDocument(TextDocumentItem.create(uri, 'fish', 0, input)); const topLevelFunctions: SyntaxNode[] = []; const autoloadedFunctions: SyntaxNode[] = []; const isAutoloadedFunctionName = isAutoloadedUriLoadsFunctionName(doc); const functionsWithReservedKeyword: SyntaxNode[] = []; for (const node of getChildNodes(root)) { if (!node.parent) continue; if (isFunctionDefinitionName(node)) { if (isAutoloadedFunctionName(node)) autoloadedFunctions.push(node); if (isTopLevelFunctionDefinition(node)) topLevelFunctions.push(node); if (isFunctionDefinitionName(node) && isReservedKeyword(node.text)) { functionsWithReservedKeyword.push(node); } } continue; } /** only functions files can have missing autoloaded functions */ const isMissingAutoloadedFunction = shouldHaveAutoloadedFunction(doc) ? autoloadedFunctions.length === 0 : false; const isMissingAutoloadedFunctionButContainsOtherFunctions = isMissingAutoloadedFunction && topLevelFunctions.length > 0; expect({ autoloadType: doc.getAutoloadType(), isMissingAutoloadedFunction, isMissingAutoloadedFunctionButContainsOtherFunctions, reservedFunctionNames: functionsWithReservedKeyword.map(n => n.text), }).toMatchObject(expected); }); }); }); describe('local functions', () => { const tests = [ { name: 'local function is unused', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function util function inner end end`, expected: { autoloadType: 'functions', unusedLocalFunction: ['inner'], localFunctions: ['inner'], }, }, { name: 'local function is used', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function util function inner end inner end`, expected: { autoloadType: 'functions', unusedLocalFunction: [], localFunctions: ['inner'], }, }, { name: 'local helper function is unused', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function util function inner end inner end function __helper end`, expected: { autoloadType: 'functions', unusedLocalFunction: ['__helper'], localFunctions: ['inner', '__helper'], }, }, { name: 'local helper function is used', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function util function inner end inner __helper end function __helper end`, expected: { autoloadType: 'functions', unusedLocalFunction: [], localFunctions: ['inner', '__helper'], }, }, { name: 'local helper completion function is used with nested functions', uri: `file://${os.homedir()}/.config/fish/completions/util.fish`, input: ` function util_cmp echo 'a\t"a" b\t"b" c\t"c"' end complete -c util -a '(util_cmp; or other_cmps)'`, expected: { autoloadType: 'completions', unusedLocalFunction: [], localFunctions: ['util_cmp'], }, }, ]; tests.forEach(({ name, uri, input, expected }) => { it(name, async () => { const tree = parser.parse(input); const root = tree.rootNode; const doc = new LspDocument(TextDocumentItem.create(uri, 'fish', 0, input)); const isAutoloadedFunctionName = isAutoloadedUriLoadsFunctionName(doc); const localFunctions: SyntaxNode[] = []; const localFunctionCalls: LocalFunctionCallType[] = []; for (const node of getChildNodes(root)) { if (isFunctionDefinitionName(node) && !isAutoloadedFunctionName(node)) { localFunctions.push(node); } if (isCommandName(node)) { localFunctionCalls.push({ node, text: node.text }); } if (doc.getAutoloadType() === 'completions') { if (isComment(node)) continue; if (isOption(node)) continue; if (node.parent && isCommandWithName(node.parent, 'complete')) { if (node.previousSibling && isMatchingCompletionOption(node.previousSibling)) { if (isString(node)) { localFunctionCalls.push({ node, text: node.text .slice(1, -1) .replace(/[()]/g, '') .replace(/[^\x00-\x7F]/g, ''), }); } else { localFunctionCalls.push({ node, text: node.text }); } continue; } } } continue; } const unusedLocalFunction = localFunctions.filter(localFunction => { const callableRange = getRange(findEnclosingScope(localFunction)!); return !localFunctionCalls.find(call => { const callRange = getRange(findEnclosingScope(call.node)!); return containsRange(callRange, callableRange) && call.text.split(/[&<>;|! ]/) .filter(cmd => !['or', 'and', 'not'].includes(cmd)) .some(t => t === localFunction.text); }); }); expect({ autoloadType: doc.getAutoloadType(), unusedLocalFunction: unusedLocalFunction.map(n => n.text), localFunctions: localFunctions.map(n => n.text), }).toMatchObject(expected); }); }); }); describe('completions', () => { const tests = [ { name: 'completions file with no completions', uri: `file://${os.homedir()}/.config/fish/functions/util.fish`, input: ` function util argparse h/help a/arguments c/command 'i/ignore-unknown' 'stop-nonopt' 'v/value=' other= -- $argv or return end `, expected: { completionFlags: [ { shortOption: 'h', longOption: 'help' }, { shortOption: 'a', longOption: 'arguments' }, { shortOption: 'c', longOption: 'command' }, { shortOption: 'i', longOption: 'ignore-unknown' }, { longOption: 'stop-nonopt' }, { shortOption: 'v', longOption: 'value' }, { longOption: 'other' }, ], completionText: `complete -c util -s h -l help complete -c util -s a -l arguments complete -c util -s c -l command complete -c util -s i -l ignore-unknown complete -c util -l stop-nonopt complete -c util -s v -l value complete -c util -l other`, }, }, ]; tests.forEach(({ name, uri, input, expected }) => { it(name, async () => { const tree = parser.parse(input); const root = tree.rootNode; const doc = new LspDocument(TextDocumentItem.create(uri, 'fish', 0, input)); const completions: CompleteFlag[] = []; for (const node of getChildNodes(root)) { if (isCommandWithName(node, 'argparse')) { const flags = findFlagsToComplete(node); completions.push(...flags); } } const builtCompletions = buildCompleteString(doc.getAutoLoadName(), completions); expect(completions).toEqual(expected.completionFlags); expect(builtCompletions).toBe(expected.completionText); }); }); }); }); describe('code-actions-handlers', () => { beforeEach(async () => { setLogger(); logger.setConsole(global.console); logger.allowDefaultConsole(); logger.setSilent(false); setupStartupMock(); }); const workspace = TestWorkspace.create().addFiles( TestFile.completion('myfunc', ''), TestFile.function('myfunc', `function myfunc argparse h/help c/command a/arguments -- $argv or return 1 echo "myfunc" end function another_func echo "another func" end`), TestFile.config(` echo "config file", 'alias ll="ls -la"', `), TestFile.function('util', 'function util; echo "util"; end'), ).initialize(); let confgDoc: LspDocument; let myFuncFDoc: LspDocument; let myFuncCDoc: LspDocument; let cmdLineDoc: LspDocument; let ws: Workspace; const onCodeActionCallback = codeActionHandlers().onCodeActionCallback; beforeAll(async () => { ws = workspace.workspace!; if (!ws) throw new Error('Workspace not initialized'); confgDoc = workspace.find('config.fish')!; myFuncFDoc = workspace.find('functions/myfunc.fish')!; myFuncCDoc = workspace.find('completions/myfunc.fish')!; cmdLineDoc = workspace.find('command-line.fish')!; ws.uris.all.forEach(uri => { const doc = documents.get(uri); if (doc) analyzer.analyze(doc); }); logger.setConnectionConsole(connection.console); }); it('ensure docs', () => { expect(ws).toBeDefined(); expect(myFuncFDoc).toBeDefined(); expect(myFuncCDoc).toBeDefined(); expect(confgDoc).toBeDefined(); expect(cmdLineDoc).toBeDefined(); }); it('can build completions for function', async () => { const doc = myFuncFDoc; const { root } = analyzer.analyze(doc).ensureParsed(); const diagnostics = await getDiagnosticsAsync(root, doc); analyzer.diagnostics.setForTesting(doc.uri, diagnostics); const req = { textDocument: { uri: doc.uri }, range: { start: { line: 1, character: 4 }, end: { line: 1, character: 4 } }, context: { diagnostics: [...analyzer.diagnostics.get(doc.uri) ?? []] }, }; const actions = await onCodeActionCallback(req); const completionActions = actions.filter(action => { return action.title.startsWith('Create completions for'); }); expect(completionActions.length).toBeGreaterThanOrEqual(1); }); it('can generate argparse completions for command-line buffer', async () => { const commandLineBufferContent = `function test_cmd argparse h/help v/verbose d/debug o/output= -- $argv or return 1 echo "test command" end`; const commandLineDoc = new LspDocument( TextDocumentItem.create( 'file:///tmp/fish.12345/command-line.fish', 'fish', 0, commandLineBufferContent, ), ); expect(commandLineDoc.isCommandlineBuffer()).toBe(true); expect(commandLineDoc.getAutoloadType()).toBe('conf.d'); testOpenDocument(commandLineDoc); analyzer.analyze(commandLineDoc).ensureParsed(); const codeActions = await onCodeActionCallback({ textDocument: { uri: commandLineDoc.uri }, range: { start: { line: 1, character: 4 }, end: { line: 1, character: 12 } }, context: { diagnostics: [], only: ['quickfix'] }, }); const argparseAction = codeActions.find(action => action.title.includes('Create completions for'), ); expect(argparseAction).toBeDefined(); expect(argparseAction?.title).toContain('test_cmd'); const edits = argparseAction?.edit?.documentChanges?.[0]; if (edits && 'edits' in edits) { const insertText = edits.edits[0]?.newText; expect(insertText).toContain('complete -c test_cmd -s h -l help'); expect(insertText).toContain('complete -c test_cmd -s v -l verbose'); expect(insertText).toContain('complete -c test_cmd -s d -l debug'); expect(insertText).toContain('complete -c test_cmd -s o -l output'); } else { fail(); } }); it('should fix all argparse unused diagnostic issues in one code action', async () => { const doc = myFuncFDoc; const { root } = analyzer.analyze(doc).ensureParsed(); const diagnostics = await getDiagnosticsAsync(root, doc); analyzer.diagnostics.setForTesting(doc.uri, diagnostics); const req = { textDocument: { uri: doc.uri }, range: { start: { line: 0, character: 0 }, end: { line: 5, character: 0 } }, context: { diagnostics: [...analyzer.diagnostics.get(doc.uri) ?? []] }, }; const actions = await onCodeActionCallback(req); const fixAllAction = actions.find(action => action.kind === 'quickfix.fixAll'); expect(fixAllAction).toBeDefined(); expect(fixAllAction?.title).toContain('Fix all auto-fixable quickfixes'); expect(fixAllAction?.edit?.changes).toBeDefined(); const changes = fixAllAction!.edit!.changes!; const edits = changes[doc.uri]; expect(edits).toHaveLength(3); const editTexts = edits?.map(e => e.newText) || []; expect(editTexts.some(text => text.includes('if set -ql _flag_help'))).toBe(true); expect(editTexts.some(text => text.includes('if set -ql _flag_command'))).toBe(true); expect(editTexts.some(text => text.includes('if set -ql _flag_arguments'))).toBe(true); editTexts.forEach(text => { expect(text).toContain('if set -ql'); expect(text).toContain('end'); }); }); }); }); export type LocalFunctionCallType = { node: SyntaxNode; text: string; }; function isMatchingCompletionOption(node: SyntaxNode) { return isMatchingOption(node, Option.create('-c', '--command').withValue()) || isMatchingOption(node, Option.create('-a', '--arguments').withMultipleValues()) || isMatchingOption(node, Option.create('-n', '--condition').withValue()); } ================================================ FILE: tests/comments-handler.test.ts ================================================ import { DiagnosticCommentsHandler, isDiagnosticComment, parseDiagnosticComment } from '../src/diagnostics/comments-handler'; import { initializeParser } from '../src/parser'; import * as Parser from 'web-tree-sitter'; import { getChildNodes } from '../src/utils/tree-sitter'; import { setLogger } from './helpers'; import { config } from '../src/config'; import { checkForInvalidDiagnosticCodes } from '../src/diagnostics/invalid-error-code'; import { isComment } from '../src/utils/node-types'; import { ErrorCodes } from '../src/diagnostics/error-codes'; let parser: Parser; describe('DiagnosticCommentsHandler', () => { setLogger( async () => { parser = await initializeParser(); }, async () => { parser.reset(); }, ); describe('isDiagnosticComment', () => { const validComments = [ '# @fish-lsp-disable', '# @fish-lsp-enable', '# @fish-lsp-disable 1001', '# @fish-lsp-enable 1001', '# @fish-lsp-disable-next-line', '# @fish-lsp-disable-next-line 1001', '# @fish-lsp-disable 1001 1002 1003', '#@fish-lsp-disable', // No space after # ' # @fish-lsp-disable', // Leading whitespace '# @fish-lsp-disable ', // Trailing whitespace ]; const invalidComments = [ '#not-a-diagnostic-comment', '# fish-lsp-disable', // Missing @ '# @fish-lsp-disablez', // Invalid command '# @fish-lsp-disable-next', // Incomplete next-line '# @fish-lsp-disable abc', // Invalid code '@fish-lsp-disable', // Missing # '# @fish-lsp-disable-prev-line', // Invalid directive '# @fish-lsp-disable-all', // Invalid command ]; const invalidCodes = [ '# @fish-lsp-disable 0000', '# @fish-lsp-disable 1001 0000 1002', ]; test.each(validComments)('should identify valid diagnostic comment: %s', (comment) => { const { rootNode } = parser.parse(comment); const commentNode = rootNode.firstChild; expect(commentNode).toBeTruthy(); expect(isDiagnosticComment(commentNode!)).toBe(true); }); test.each(invalidComments)('should reject invalid diagnostic comment: %s', (comment) => { const { rootNode } = parser.parse(comment); const commentNode = rootNode.firstChild; expect(commentNode).toBeTruthy(); expect(isDiagnosticComment(commentNode!)).toBe(false); }); invalidCodes.forEach((comment) => { it(`should detect invalid diagnostic codes in comment: ${comment}`, () => { const { rootNode } = parser.parse(comment); const commentNode = getChildNodes(rootNode).find(isComment)!; const isDiagnostic = isDiagnosticComment(commentNode); const diagnostics = checkForInvalidDiagnosticCodes(commentNode); const range = diagnostics[0]!.range; expect(isDiagnostic).toBe(true); expect(diagnostics).toHaveLength(1); expect(range.end.character - range.start.character).toBe(4); }); }); }); describe('parseDiagnosticComment', () => { it('should parse basic enable/disable comments', () => { const input = '# @fish-lsp-disable'; const { rootNode } = parser.parse(input); const result = parseDiagnosticComment(rootNode.firstChild!); expect(result).toEqual({ action: 'disable', target: 'line', codes: ErrorCodes.allErrorCodes, lineNumber: 0, }); }); it('should parse comments with specific error codes', () => { const input = '# @fish-lsp-disable 1001 1002'; const { rootNode } = parser.parse(input); const result = parseDiagnosticComment(rootNode.firstChild!); expect(result).toEqual({ action: 'disable', target: 'line', codes: [1001, 1002], lineNumber: 0, }); }); it('should parse next-line directives', () => { const input = '# @fish-lsp-disable-next-line 1001'; const { rootNode } = parser.parse(input); const result = parseDiagnosticComment(rootNode.firstChild!); expect(result).toEqual({ action: 'disable', target: 'next-line', codes: [1001], lineNumber: 0, }); }); it('should handle invalid error codes', () => { const input = '# @fish-lsp-disable 1001 0000 1002'; const { rootNode } = parser.parse(input); const result = parseDiagnosticComment(rootNode.firstChild!); expect(result).toEqual({ action: 'disable', target: 'line', codes: [1001, 1002], lineNumber: 0, invalidCodes: ['0000'], }); }); }); describe('DiagnosticCommentsHandler state management', () => { let handler: DiagnosticCommentsHandler; beforeEach(() => { config.fish_lsp_diagnostic_disable_error_codes = []; handler = new DiagnosticCommentsHandler(); }); it('should maintain proper state stack depth', () => { const input = ` # @fish-lsp-disable echo "disabled" # @fish-lsp-disable-next-line echo "next line disabled" echo "back to disabled" # @fish-lsp-enable echo "enabled"`; const { rootNode } = parser.parse(input); getChildNodes(rootNode).forEach(node => { handler.handleNode(node); // Check stack depth at each stage if (node.type === 'command' && node.text.includes('disabled')) { expect(handler.getStackDepth()).toBeGreaterThan(1); } else if (node.type === 'command' && node.text.includes('enabled')) { expect(handler.getStackDepth()).toBe(2); // enabled doesn't replace initial state } }); }); it('should properly handle nested and overlapping directives', () => { const input = ` # @fish-lsp-disable 1001 # @fish-lsp-disable 1002 echo "both disabled" # @fish-lsp-enable 1001 echo "only 1002 disabled" # @fish-lsp-enable echo "all enabled"`; const { rootNode } = parser.parse(input); getChildNodes(rootNode).forEach(node => { handler.handleNode(node); if (node.type === 'command') { if (node.text.includes('both disabled')) { expect(handler.isCodeEnabled(1001)).toBe(false); expect(handler.isCodeEnabled(1002)).toBe(false); } else if (node.text.includes('only 1002 disabled')) { expect(handler.isCodeEnabled(1001)).toBe(true); expect(handler.isCodeEnabled(1002)).toBe(false); } else if (node.text.includes('all enabled')) { expect(handler.isCodeEnabled(1001)).toBe(true); expect(handler.isCodeEnabled(1002)).toBe(true); } } }); }); it('should properly cleanup next-line directives', () => { const input = ` echo "normal" # @fish-lsp-disable-next-line 1001 echo "disabled" echo "back to normal"`; const { rootNode } = parser.parse(input); getChildNodes(rootNode).forEach(node => { handler.handleNode(node); if (node.type === 'command') { if (node.text === 'echo "normal"') { expect(handler.isCodeEnabled(1001)).toBe(true); } else if (node.text === 'echo "back to normal"') { expect(handler.isCodeEnabled(1001)).toBe(true); } else if (node.text === 'echo "disabled"') { expect(handler.isCodeEnabled(1001)).toBe(false); } } }); }); it('should provide line-by-line state information', () => { const input = ` # Normal line # @fish-lsp-disable 1001 # @fish-lsp-disable-next-line 1002 # This line has 1002 disabled aaa # This line should only have 1001 disabled # @fish-lsp-disable-next-line 1003 echo 'This line should have 1001 and 1003 disabled' # @fish-lsp-enable # @fish-lsp-disable-next-line echo 'all disabled' echo 'all enabled' # @fish-lsp-disable`; const { rootNode } = parser.parse(input); const handler = new DiagnosticCommentsHandler(); getChildNodes(rootNode).forEach(node => handler.handleNode(node)); handler.finalizeStateMap(rootNode.text.split('\n').length + 1); // Finalize the state map // handler.finalizeStateMapFromRootNode(rootNode); // Get line-by-line state dump // logInputDiagnosticStateMap(rootNode, handler); const children = getChildNodes(rootNode); const checkNextLine = children.find(n => n.text === '# This line has 1002 disabled')!; const checkAfterNextLine = children.find(n => n.text === 'aaa')!; // console.log(`line 4, has 1002 ${handler.isCodeEnabledAtNode(1002, checkNextLine) ? 'enabled' : 'disabled'} | ${checkNextLine.text}`); // console.log(`line 5, has 1002 ${handler.isCodeEnabledAtNode(1002, checkAfterNextLine) ? 'enabled' : 'disabled'} | ${checkAfterNextLine.text}`); expect(handler.isCodeEnabledAtNode(1002, checkNextLine)).toBe(false); expect(handler.isCodeEnabledAtNode(1002, checkAfterNextLine)).toBe(true); // }); }); }); ================================================ FILE: tests/complete-symbol.test.ts ================================================ import { initializeParser } from '../src/parser'; import { createTestWorkspace, setLogger, locationAsString, fakeDocumentTrimUri } from './helpers'; // import { isLongOption, isOption, isShortOption, NodeOptionQueryText } from '../src/utils/node-types'; import * as Parser from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { Range, SymbolKind } from 'vscode-languageserver'; // import { isFunctionDefinitionName } from '../src/parsing/function'; import { analyzer, Analyzer } from '../src/analyze'; import { getCompletionSymbol, CompletionSymbol } from '../src/parsing/complete'; import { LspDocument } from '../src/document'; import { getReferences } from '../src/references'; import { fail } from 'assert'; import TestWorkspace from './test-workspace-utils'; let parser: Parser; describe('parsing symbols', () => { setLogger(); beforeEach(async () => { await setupProcessEnvExecFile(); parser = await initializeParser(); await Analyzer.initialize(); await setupProcessEnvExecFile(); }); describe('completion --> to argparse', () => { const workspace = TestWorkspace.create().addFiles({ relativePath: 'functions/foo.fish', content: [ 'function foo', ' argparse -i h/help long other-long s \'1\' -- $argv', ' or return', ' echo hi', 'end', ].join('\n'), }, { relativePath: 'completions/foo.fish', content: [ 'complete -c foo -f -k', 'complete -c foo -s h -l help', 'complete -c foo -k -l long', 'complete -c foo -k -l other-long -d \'other long\'', 'complete -c foo -k -s s -d \'short\'', 'complete -c foo -k -s 1 -d \'1 item\'', ].join('\n'), }, { relativePath: 'conf.d/bar.fish', content: [ 'complete -c bar -f', 'complete -c bar -s h -l help', 'complete -c bar -s 1 -l oneline', '', 'function bar', ' argparse h/help 1/oneline -- $argv', ' or return', ' echo inside bar', 'end', ].join('\n'), }, { relativePath: 'conf.d/baz.fish', content: [ 'function baz', ' argparse h/help -- $argv', ' or return', ' if set -ql _flag_help', ' echo \'help message\'', ' end', ' echo \'inside baz\'', 'end', 'complete -c baz -f', 'complete -c baz -s h -l help', 'function baz_helper', ' foo --help', 'end', ].join('\n'), }, ).initialize(); // const workspace = test_workspace.workspace; beforeEach(async () => { parser = await initializeParser(); }); // it('completion >>(((*> function', () => { it('completion simple => `complete -c foo -l help` -> `argparse h/help`', async () => { const expectedOpts = [ 'foo -h', 'foo --help', 'foo --long', 'foo --other-long', 'foo -s', 'foo -1', ]; workspace.analyzeAllFiles(); const searchDoc = workspace.getDocument('functions/foo.fish')!; const funcName = searchDoc?.getAutoLoadName() as string; const results: CompletionSymbol[] = []; const result = analyzer.findNodes((n: SyntaxNode, doc: LspDocument) => { if (['functions', ''].includes(doc.getAutoloadType())) { return false; } const completeSymbol = getCompletionSymbol(n, doc); if (completeSymbol.isNonEmpty() && completeSymbol.hasCommandName(funcName)) { results.push(completeSymbol); return true; } return false; }); const uniqueUris = new Set([...result.filter(res => res?.uri)]); console.log({ uniqueUris, uris: results.map(res => res?.document?.uri), }); expect(uniqueUris.size === 1).toBeTruthy(); // results.forEach((res) => { // console.log({ // res: res.toUsage(), // uri: res.doc?.uri, // }); // }); const usages = results.map(res => res.toUsage()); expect(usages).toEqual(expectedOpts); const helpOpt = results.find(opt => opt.isMatchingRawOption('--help'))!; expect(helpOpt.toUsage()).toBe('foo --help'); const helpOptPosition = helpOpt.getRange().start; // const helpOptLocation = Location.create(helpOpt.doc!.uri, helpOpt.getRange()) const defSymbol = analyzer.getDefinition(helpOpt.document!, helpOptPosition); if (!defSymbol) { fail(); } expect({ name: defSymbol.name, uri: defSymbol.uri, fishKind: defSymbol.fishKind, parentName: defSymbol.parent!.name, }).toEqual({ name: '_flag_help', uri: searchDoc.uri, fishKind: 'ARGPARSE', parentName: 'foo', }); const refLocations = getReferences(searchDoc, defSymbol.selectionRange.start); // console.log(JSON.stringify({ // refLocations: refLocations.map(r => ({ // location: locationAsString(r), // text: analyzer.getTextAtLocation(r) // })), // }, null, 2)); const locationUris = refLocations.map(l => { const doc = analyzer.getDocument(l.uri)!; return fakeDocumentTrimUri(doc); }); for (const uri of locationUris) { expect([ 'functions/foo.fish', 'completions/foo.fish', 'conf.d/baz.fish', ].includes(uri)).toBeTruthy(); } expect( refLocations.map(l => { const doc = analyzer.getDocument(l.uri)!; return { uri: fakeDocumentTrimUri(doc), range: l.range, text: analyzer.getTextAtLocation(l), }; }).every((location) => { return [ { uri: 'functions/foo.fish', range: Range.create(1, 18, 1, 22), text: 'help', }, { uri: 'completions/foo.fish', range: Range.create(1, 24, 1, 28), text: 'help', }, { uri: 'conf.d/baz.fish', range: Range.create(11, 11, 11, 16), text: 'help', }, ].some(loc => loc.uri === location.uri && loc.range.start.line === location.range.start.line && loc.range.start.character === location.range.start.character && loc.range.end.line === location.range.end.line && loc.range.end.character === location.range.end.character && loc.text === location.text); }), ).toBeTruthy(); }); it.skip('argparse simple => `argparse h/help -- $argv` -> `complete -c foo -l help`', () => { const searchDoc = workspace.getDocument('functions/foo.fish')!; // const funcName = searchDoc?.getAutoLoadName() as string; const funcSymbol = analyzer.getFlatDocumentSymbols(searchDoc.uri).find((symbol) => { if (symbol.name === '_flag_help' && symbol?.parent && symbol.parent.name === 'foo') { return true; } return false; }); const defSymbol = analyzer.getDefinition(searchDoc, funcSymbol!.selectionRange.start); if (!defSymbol) { fail(); } const refLocations = getReferences(searchDoc, defSymbol.selectionRange.start); expect(refLocations.map(l => { const doc = analyzer.getDocument(l.uri)!; return { uri: fakeDocumentTrimUri(doc), range: l.range, text: analyzer.getTextAtLocation(l), }; })).toEqual([ { uri: 'functions/foo.fish', range: Range.create(1, 18, 1, 22), text: 'help', }, { uri: 'completions/foo.fish', range: Range.create(1, 24, 1, 28), text: 'help', }, { uri: 'conf.d/baz.fish', range: Range.create(11, 11, 11, 16), text: 'help', }, ]); }); it('completion advanced => `complete -c foo -l other-long` -> `argparse --other-long`', () => { const searchDoc = workspace.getDocument('completions/foo.fish')!; const funcName = searchDoc?.getAutoLoadName() as string; const results: CompletionSymbol[] = []; analyzer.findNodes((n: SyntaxNode, doc: LspDocument) => { if (['functions', ''].includes(doc.getAutoloadType())) { return false; } const completeSymbol = getCompletionSymbol(n, doc); if (completeSymbol.isNonEmpty() && completeSymbol.hasCommandName(funcName)) { results.push(completeSymbol); return true; } return false; }); const foundOpt = results.find(opt => opt.isMatchingRawOption('--other-long')); expect(foundOpt).toBeDefined(); expect(foundOpt?.toUsage()).toBe('foo --other-long'); if (!foundOpt) { fail(); } const foundDef = analyzer.getDefinition(foundOpt.document!, foundOpt.getRange().start)!; console.log({ foundDef: foundDef?.name, }); const foundDefDoc = analyzer.getDocument(foundDef.uri)!; /** * Confirm that getReferences works when passing in both: * a reference and a definition Location */ const foundRef = getReferences(foundDefDoc, foundDef.selectionRange.start); const foundRefOg = getReferences(searchDoc, foundOpt.getRange().start); // console.log(JSON.stringify({ // foundRef: foundRef.map(r => ({ uri: r.uri, range: r.range })), // foundRefOg: foundRefOg.map(r => ({ uri: r.uri, range: r.range })), // }, null, 2)); expect(foundRef).toEqual(foundRefOg); expect(foundRefOg.map(r => { const doc = analyzer.getDocument(r.uri); return { uri: fakeDocumentTrimUri(doc!), range: r.range, text: analyzer.getTextAtLocation(r), }; })).toEqual([ { uri: 'functions/foo.fish', range: Range.create(1, 28, 1, 38), text: 'other-long', }, { uri: 'completions/foo.fish', range: Range.create(3, 22, 3, 32), text: 'other-long', }, ]); }); it('command => `complete -c baz` -> `function baz;end;`', () => { const searchDoc = workspace.getDocument('conf.d/baz.fish')!; const searchSymbol = analyzer.getFlatDocumentSymbols(searchDoc.uri).find((symbol) => { return symbol.name === 'baz' && symbol.kind === SymbolKind.Function; }); if (!searchSymbol) { fail(); } const refLocations = getReferences(searchDoc, searchSymbol.selectionRange.start); refLocations.forEach(l => { console.log({ location: locationAsString(l), text: analyzer.getTextAtLocation(l), }); }); expect(refLocations).toHaveLength(3); expect( refLocations.map(l => ({ uri: fakeDocumentTrimUri(analyzer.getDocument(l.uri)!), range: l.range, }), ), ).toEqual([ { uri: 'conf.d/baz.fish', range: Range.create(0, 9, 0, 12) }, { uri: 'conf.d/baz.fish', range: Range.create(8, 12, 8, 15) }, { uri: 'conf.d/baz.fish', range: Range.create(9, 12, 9, 15) }, ]); }); }); }); ================================================ FILE: tests/completion-shell.test.ts ================================================ import { setLogger } from './helpers'; import { escapeCmd, shellComplete } from '../src/utils/completion/shell'; describe('check completions', () => { setLogger(); describe('test escaping input', () => { it("echo '", () => { const cmd = 'echo \''; const escapedCmd = escapeCmd(cmd); // console.log({ cmd, escapedCmd }); expect(escapedCmd.length).toBeGreaterThan(cmd.length); }); it('echo "', async () => { const cmd = 'echo \"'; const escapedCmd = escapeCmd(cmd); // console.log({ cmd, escapedCmd }); expect(escapedCmd.length).toBeGreaterThan(cmd.length); }); it('echo $', async () => { const cmd = 'echo $'; const escapedCmd = escapeCmd(cmd); // console.log({ cmd, escapedCmd }); expect(escapedCmd.length).toBeGreaterThan(cmd.length); }); it('echo $', async () => { const cmd = 'echo $'; const escapedCmd = escapeCmd(cmd); // console.log({ cmd, escapedCmd }); expect(escapedCmd.length).toBeGreaterThan(cmd.length); }); it('echo \\"$', async () => { const cmd = 'echo \"$'; const escapedCmd = escapeCmd(cmd); // console.log({ cmd, escapedCmd }); expect(escapedCmd.length).toBeGreaterThan(cmd.length); }); it('echo \\\\n$', async () => { const cmd = 'echo \\\n$'; const escapedCmd = escapeCmd(cmd); // console.log({ cmd, escapedCmd }); expect(escapedCmd.length).toBeGreaterThan(cmd.length); }); }); describe('fish-lsp', () => { it('fish-lsp --', async () => { const completions = await shellComplete('fish-lsp --'); const output = [ ['--help', 'Show help information'], ['--help-all', 'Show all help information'], ['--help-man', 'Show raw manpage'], ['--help-short', 'Show short help information'], ['--version', 'Show lsp version'], ]; expect(completions).toEqual(output); }); it('fish-lsp ', async () => { const completions = await shellComplete('fish-lsp '); const items = completions.map(([first, rest]) => first); expect(items).toContain('start'); expect(items).toContain('complete'); }); it('fish-lsp start -', async () => { const output = await shellComplete('fish-lsp start -'); const expected = [ ['--disable', ''], ['--dump', 'dump output and stop server'], ['--enable', ''], ]; for (const [name, detail] of output) { expect(expected).toContainEqual([name, detail]); } }); it('fish-lsp start --enable ', async () => { const output = await shellComplete('fish-lsp start --enable '); expect(output.length).toBeGreaterThan(6); // console.log(output) }); }); describe('builtins', () => { it('pwd -', async () => { const completions = await shellComplete('pwd -'); // console.log(completions); const expected = [ ['-h', 'Display help and exit'], ['-L', 'Print working directory without resolving symlinks'], ['-P', 'Print working directory with symlinks resolved'], ['--help', 'Display help and exit'], ['--logical', 'Print working directory without resolving symlinks'], ['--physical', 'Print working directory with symlinks resolved'], ]; for (const item of expected) { expect(completions).toContainEqual(item); } }); it('function ', async () => { const completions = await shellComplete('function '); expect(completions.length).toBeGreaterThanOrEqual(61); // 61 is the number of builtins }); it('function foo -', async () => { const completions = await shellComplete('function foo -'); const expected = [ ['-a', 'Specify named arguments'], ['-d', 'Set function description'], ['-e', 'Make the function a generic event handler'], ['-j', 'Make the function a job exit event handler'], ['-p', 'Make the function a process exit event handler'], ['-S', 'Do not shadow variable scope of calling function'], ['-s', 'Make the function a signal event handler'], ['-V', 'Snapshot and define local variable'], ['-v', 'Make the function a variable update event handler'], ['-w', 'Inherit completions from the given command'], ['--argument-names', 'Specify named arguments'], ['--description', 'Set function description'], ['--inherit-variable', 'Snapshot and define local variable'], [ '--no-scope-shadowing', 'Do not shadow variable scope of calling function', ], ['--on-event', 'Make the function a generic event handler'], ['--on-job-exit', 'Make the function a job exit event handler'], [ '--on-process-exit', 'Make the function a process exit event handler', ], ['--on-signal', 'Make the function a signal event handler'], [ '--on-variable', 'Make the function a variable update event handler', ], ['--wraps', 'Inherit completions from the given command'], ]; for (const item of expected) { expect(completions).toContainEqual(item); } }); it('ab', async () => { const completions = await shellComplete('ab'); expect(completions.map(item => item[0])).toContain('abbr'); }); it('__fish', async () => { const completions = await shellComplete('__fish'); // console.log(completions); expect(completions.length).toBeGreaterThan(61); }); it('set -', async () => { const completions = await shellComplete('set -'); const expected = [ ['-a', 'Append value to a list'], ['-e', 'Erase variable'], ['-f', 'Make variable function-scoped'], ['-g', 'Make variable scope global'], ['-h', 'Display help and exit'], ['-L', 'Do not truncate long lines'], ['-l', 'Make variable scope local'], ['-n', 'List the names of the variables, but not their value'], ['-p', 'Prepend value to a list'], ['-q', 'Test if variable is defined'], ['-S', 'Show variable'], ['-U', 'Share variable persistently across sessions'], ['-u', 'Do not export variable to subprocess'], ['-x', 'Export variable to subprocess'], ['--append', 'Append value to a list'], ['--erase', 'Erase variable'], ['--export', 'Export variable to subprocess'], ['--function', 'Make variable function-scoped'], ['--global', 'Make variable scope global'], ['--help', 'Display help and exit'], ['--local', 'Make variable scope local'], ['--long', 'Do not truncate long lines'], ['--names', 'List the names of the variables, but not their value'], ['--path', 'Make variable as a path variable'], ['--prepend', 'Prepend value to a list'], ['--query', 'Test if variable is defined'], ['--show', 'Show variable'], ['--unexport', 'Do not export variable to subprocess'], ['--universal', 'Share variable persistently across sessions'], ['--unpath', 'Make variable not as a path variable'], ]; // console.log(completions); for (const item of expected) { expect(completions).toContainEqual(item); } }); it('set -q ', async () => { const completions = await shellComplete('set -q '); expect(completions.length).toBeGreaterThanOrEqual(1); }); it('complete -c _cmd -', async () => { const completions = await shellComplete('complete -c _cmd -'); const expected = [ ['-a', 'Space-separated list of possible arguments'], [ '-C', 'Print completions for a commandline specified as a parameter', ], ['-c', 'Command to add completion to'], ['-d', 'Description of completion'], ['-e', 'Remove completion'], ['-F', 'Always use file completion'], ['-f', "Don't use file completion"], ['-h', 'Display help and exit'], ['-k', 'Keep order of arguments instead of sorting alphabetically'], ['-l', 'GNU-style long option to complete'], ['-n', 'Completion only used if command has zero exit status'], ['-o', 'Old style long option to complete'], ['-p', 'Path to add completion to'], ['-r', 'Require parameter'], ['-s', 'POSIX-style short option to complete'], ['-w', 'Inherit completions from specified command'], ['-x', "Require parameter and don't use file completion"], ['--arguments', 'Space-separated list of possible arguments'], ['--command', 'Command to add completion to'], [ '--condition', 'Completion only used if command has zero exit status', ], ['--description', 'Description of completion'], [ '--do-complete', 'Print completions for a commandline specified as a parameter', ], ['--erase', 'Remove completion'], ['--exclusive', "Require parameter and don't use file completion"], ['--force-files', 'Always use file completion'], ['--help', 'Display help and exit'], [ '--keep-order', 'Keep order of arguments instead of sorting alphabetically', ], ['--long-option', 'GNU-style long option to complete'], ['--no-files', "Don't use file completion"], ['--old-option', 'Old style long option to complete'], ['--path', 'Path to add completion to'], ['--require-parameter', 'Require parameter'], ['--short-option', 'POSIX-style short option to complete'], ['--wraps', 'Inherit completions from specified command'], ]; for (const item of expected) { expect(completions).toContainEqual(item); } }); }); describe('commands', () => { it("''(EMPTY INPUT)", async () => { const completions = await shellComplete(''); // console.log(completions.slice(0, 10)); expect(completions.length).toBeGreaterThan(61); }); it('echo -', async () => { const completions = await shellComplete('echo -'); const expected = [ ['-E', 'Disable backslash escapes'], ['-e', 'Enable backslash escapes'], ['-n', 'Do not output a newline'], ['-s', 'Do not separate arguments with spaces'], ]; // console.log(completions); for (const item of expected) { expect(completions).toContainEqual(item); } }); it('echo "$', async () => { const completions = await shellComplete('echo "$'); // console.log(completions); expect(completions.length).toBeGreaterThan(1); const items = completions.map(item => item[0]); items.forEach(name => { expect(name.startsWith('$')).toBeTruthy(); }); // expect(items.filter(i => i.includes('$PWD'))).toBeTruthy(); expect(items).toContain('$PWD'); expect(items).toContain('$HOME'); expect(items).toContain('$fish_pid'); }); it("echo \'$", async () => { const completions = await shellComplete("echo '$"); expect(completions.length).toBe(0); }); it('echo \\\\n$', async () => { const completions = await shellComplete('echo \\\n$'); const items = completions.map(item => item[0]); expect(items.length).toBeGreaterThan(0); expect(items).toContain('$PWD'); expect(items).toContain('$HOME'); expect(items).toContain('$fish_pid'); }); it('echo "$PATH$', async () => { const completions = await shellComplete('echo "$HOME$'); const items = completions.map(item => item[0]); expect(items.length).toBeGreaterThan(0); expect(items).toContain('$HOME$PWD'); expect(items).toContain('$HOME$HOME'); expect(items).toContain('$HOME$fish_pid'); }); }); describe('commands w/ subcommands', () => { it.only('string ', async () => { const completions = await shellComplete('string '); expect(completions.length).toBeGreaterThanOrEqual(17); // console.log(completions); }); it.only('git ', async () => { const completions = await shellComplete('git '); expect(completions.length).toBeGreaterThan(3); // console.log(completions); }); }); }); ================================================ FILE: tests/completion-startup-config.test.ts ================================================ import { runSetupItems, SetupItem, SetupItemsFromCommandConfig } from '../src/utils/completion/startup-config'; import { CompletionItemMap } from '../src/utils/completion/startup-cache'; import { setLogger } from './helpers'; import { StaticItems } from '../src/utils/completion/static-items'; import { execCmd } from '../src/utils/exec'; import { ConfigSchema } from '../src/config'; import { FishCompletionItemKind } from '../src/utils/completion/types'; /** * NOTE: since the test suite is dependent on the machine's shell environment, we need to * account for the possibility of certain commands specifically not being used at all by the user, * while keeping the test suite's confirmation that the command will work if it is used. */ namespace AllowedEmptyCommands { const allowedEmptyCommands = [ { kind: FishCompletionItemKind.ALIAS, command: 'alias | count' }, { kind: FishCompletionItemKind.ABBR, command: 'abbr --show | count' }, ]; type AllowedEmptyCommandResult = { kind: FishCompletionItemKind; command: string; count: number; }; export const items: AllowedEmptyCommandResult[] = []; export async function setup(): Promise { const results: AllowedEmptyCommandResult[] = []; for (const { kind, command } of allowedEmptyCommands) { const output = await execCmd(command, { interactiveMode: true }); const count = parseInt(output.join('') ?? '0', 10); results.push({ kind, command, count }); } return results; } export function hasKind(kind: FishCompletionItemKind): boolean { const item = items.find(item => item.kind === kind); return item ? item.count === 0 : false; } export function getCountForKind(kind: FishCompletionItemKind): number { return items.find(item => item.kind === kind)?.count || 0; } } /** * Utility for performance testing of SetupItems Initialization */ export type SetupResult = SetupItem & { results: string[]; }; export async function simpleParrallelTestSetupItemsInitializer( items: SetupItem[] = SetupItemsFromCommandConfig, ): Promise { const settled = await Promise.allSettled( items.map((item) => execCmd(item.command, { interactiveMode: true }).then((results) => ({ ...item, results, })), ), ); return settled.map((outcome, i) => ({ ...items[i]!, results: outcome.status === 'fulfilled' ? outcome.value.results : [], })); } describe('Test completions/startup-config.ts `SetupItem` commands', () => { setLogger(); beforeAll(async () => { await AllowedEmptyCommands.setup(); }); describe('test different StartupItem initialization designs', () => { // use to see what is actually being passed to fish for each command, // and confirm it is being parsed correctly (i.e. no unexpected escaping issues, etc.) it.skip('print SetupItems.command string interpretation passed to fish', () => { console.log(SetupItemsFromCommandConfig.map(item => { return { kind: item.fishKind, command: item.command, }; })); expect(SetupItemsFromCommandConfig.length).toBeGreaterThanOrEqual(5); }); it('parallel SetupItem.command execution', async () => { const setupResults = await simpleParrallelTestSetupItemsInitializer(); // for (const { detail, fishKind, results } of setupResults) { // console.log(`${detail} (${fishKind}): ${results.length} items`); // } expect(setupResults.length).toBeGreaterThanOrEqual(5); }); it('better SetupItem.command execution', async () => { const results = await runSetupItems(); // console.log(results) expect(results.length).toBeGreaterThanOrEqual(5); }); }); describe('CompletionItemMap', () => { // setup/teardown CompletionItemMap for all tests in this block let completionItemMap: CompletionItemMap; beforeAll(async () => { completionItemMap = await CompletionItemMap.initialize(); }); afterAll(() => { completionItemMap = new CompletionItemMap(); }); it('should initialize CompletionItemMap without error', () => { console.log('-'.repeat(80)); console.log('CompletionItemMap initialized with the following item counts:'); completionItemMap.entries().forEach(([kind, items]) => { console.log(`- ${kind}: ${items?.length || 0} items`); expect(items).toBeDefined(); // We distinguish between values which a user might not have defined (i.e., no aliases or abbrs) // Which 0 items is an acceptable result for if (AllowedEmptyCommands.hasKind(kind)) { expect(items!.length).toBeGreaterThanOrEqual(AllowedEmptyCommands.getCountForKind(kind)); } else { // Non-empty command kinds should have some items (default items are added to cache) expect(items!.length).toBeGreaterThan(0); } }); console.log(`Total kinds in CompletionItemMap: ${completionItemMap.allKinds.length}`); console.log('-'.repeat(80)); }); describe('StaticItems', () => { it('confirm all static items were added to CompletionItemMap', () => { expect(Object.keys(StaticItems).length).toBeGreaterThan(0); Object.keys(StaticItems).forEach(itemType => { const items = completionItemMap.allOfKinds(itemType as any); expect(items.length).toBeGreaterThan(0); }); }); it('verbose static item check', () => { expect(completionItemMap.allOfKinds('function').length).toBeGreaterThan(0); expect(completionItemMap.allOfKinds('command').length).toBeGreaterThan(0); expect(completionItemMap.allOfKinds('variable').length).toBeGreaterThan(0); expect(completionItemMap.allOfKinds('status').length).toBeGreaterThan(0); }); it('`fish_lsp*` variable check', () => { const foundItems = completionItemMap.allOfKinds('variable').filter(item => item.label.startsWith('fish_lsp')); expect(foundItems.length).toBeGreaterThan(0); for (const key of Object.keys(ConfigSchema.shape)) { const match = foundItems.find(item => item.label === key); // console.log({ // label: match!.label, // documentation: match!.documentation, // }) expect(match).toBeDefined(); expect(match!.documentation).toBeDefined(); } }); }); describe('test CompletionItemMap utility methods', () => { it('get()', () => { expect(completionItemMap.get('function')).toBeDefined(); expect(completionItemMap.get('function')!.length).toBeGreaterThan(0); expect(completionItemMap.get('command')).toBeDefined(); expect(completionItemMap.get('command')!.length).toBeGreaterThan(0); }); it('allKinds()', () => { const kinds = completionItemMap.allKinds; expect(kinds.length).toBeGreaterThan(0); expect(kinds).toContain('function'); expect(kinds).toContain('command'); expect(kinds).toContain('variable'); expect(kinds).toContain('status'); }); it('findLabel()', () => { // define type for testing multiple items type TestItemInput = { label: string; kinds: FishCompletionItemKind[]; }; type TestItemExpectedOutput = { found: boolean; }; // input tested on should work in ci enviornment, so the most straightforward way // to achieve this is by using static items and config variables, which behave // deterministically across machines (since they are defined in code, not user config) const testItems: { inputParams: TestItemInput; expectedOutput: TestItemExpectedOutput; }[] = [ { inputParams: { label: 'fish_lsp_fish_path', kinds: [] }, expectedOutput: { found: true }, }, { inputParams: { label: 'fish_lsp_fish_path', kinds: ['variable'] }, expectedOutput: { found: true }, }, { inputParams: { label: 'fish_add_path', kinds: ['function'] }, expectedOutput: { found: true }, }, { inputParams: { label: 'fish_add_path', kinds: ['variable'] }, expectedOutput: { found: false }, }, { inputParams: { label: 'non_existent_label', kinds: [] }, expectedOutput: { found: false }, }, ]; for (const { inputParams, expectedOutput } of testItems) { const { label, kinds } = inputParams; const foundItem = completionItemMap.findLabel(label, ...kinds); if (expectedOutput.found) expect(foundItem).toBeDefined(); else expect(foundItem).toBeUndefined(); } }); }); // TODO: confirm `mkdir` is included in output of `complete --do-complete` command (issue #154) describe('TEMPORARY TEST FOR #154 `mkdir` command', () => { it('confirm `mkdir` is included in output of `complete --do-complete` command', async () => { const output = await runSetupItems( SetupItemsFromCommandConfig.find(item => item.fishKind === 'command') ? [SetupItemsFromCommandConfig.find(item => item.fishKind === 'command')!] : [], ); let foundMkdir = false; for (const item of output.flatMap(item => item.results)) { if (item.startsWith('mkdir')) { foundMkdir = true; break; } } output.forEach(item => { const formattedResults = item.results.map(line => line.trim().split('\t')); const closestResults = formattedResults.filter(([label]) => label?.startsWith('mk')).sort((a, b) => { const target = 'mkdir'; const similarity = (label: string) => { let i = 0; while (i < label.length && i < target.length && label[i] === target[i]) { i++; } return i; }; return similarity(b[0] ?? '') - similarity(a[0] ?? '') || (a[0] ?? '').localeCompare(b[0] ?? ''); }); const mkdirLines = formattedResults.filter(([label]) => label?.startsWith('mkdir')).map(splitLine => splitLine.join('\t')); const prettyResult = { mkdirFound: foundMkdir, mkdirLines, totalResults: item.results.length, closestResults: closestResults.map((splitLine) => splitLine.join('\t')), }; console.log({ kind: item.fishKind, command: item.command, // resultsRaw: item.results, resultsFormatted: prettyResult, topLevel: item.topLevel, }); }); console.log('Final check: was \'mkdir\' found in any command output?', foundMkdir); console.log('-'.repeat(80)); expect(foundMkdir).toBe(true); expect(output.length).toBeGreaterThan(0); expect(output.flatMap(o => o.results).length).toBeGreaterThan(0); }); }); it('check `mkdir` in cache', () => { const mkdirItem = completionItemMap.findLabel('mkdir'); console.log('Found `mkdir` item in cache:', mkdirItem); expect(mkdirItem).toBeDefined(); }); it('confirm `mkdir` item in cache has correct kind', () => { const mkdirItem = completionItemMap.findLabel('mkdir', 'command'); // console.log('`mkdir` item details:', mkdirItem); expect(mkdirItem).toBeDefined(); }); }); }); ================================================ FILE: tests/completion-variable-expansion.test.ts ================================================ import { CompletionParams, InsertReplaceEdit, TextEdit, Range, CompletionItem } from 'vscode-languageserver'; import { createFakeLspDocument, setupStartupMock, createMockConnection, rangeAsString } from './helpers'; import { documents } from '../src/document'; import { analyzer, Analyzer } from '../src/analyze'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { initializeParser } from '../src/parser'; // Setup startup mocks before importing FishServer setupStartupMock(); // Now import FishServer after the mock is set up import FishServer from '../src/server'; const logCompletionItem = (item: CompletionItem) => { const textEdit = item.textEdit as TextEdit; console.log({ label: item.label, insertText: item.insertText, kind: item.kind, documentation: item.documentation?.toString().splitNewlines().slice(0, 2).join('\n') + '...', labelDetails: item.labelDetails, data: item.data, detail: item.detail, textEdit: { newText: textEdit.newText, range: rangeAsString(textEdit.range as Range), }, }); }; describe('Completion Handler - Variable Expansion', () => { let server: FishServer; beforeEach(async () => { await setupProcessEnvExecFile(); await initializeParser(); await Analyzer.initialize(); // Create mock connection const mockConnection = createMockConnection(); const mockInitializeParams = { processId: 1234, rootUri: 'file:///test/workspace', rootPath: '/test/workspace', capabilities: { workspace: { workspaceFolders: true, }, textDocument: { completion: { completionItem: { snippetSupport: true, }, }, }, }, workspaceFolders: [], }; const result = await FishServer.create(mockConnection, mockInitializeParams as any); server = result.server; server.backgroundAnalysisComplete = true; // Enable completions }); // Helper function to find PATH variable completions const findPathCompletion = (result: any) => { return result.items.find((item: any) => item.label === 'PATH' || item.insertText === 'PATH' || item.label?.includes('PATH') && !item.label.includes('ALACRITTY'), ); }; describe('Variable completion for $PATH with various prefixes', () => { it('should complete echo $$PA to echo $$PATH', async () => { const content = 'echo $$PA'; const doc = createFakeLspDocument('test.fish', content); analyzer.analyze(doc); const params: CompletionParams = { textDocument: { uri: doc.uri }, position: { line: 0, character: content.length }, }; const result = await server.onCompletion(params); expect(result).toBeDefined(); expect(result.items).toBeDefined(); expect(result.items.length).toBeGreaterThan(0); const pathItem = findPathCompletion(result); expect(pathItem).toBeDefined(); }); it('should complete echo $ to echo $PATH', async () => { const content = 'echo $'; const doc = createFakeLspDocument('test.fish', content); analyzer.analyze(doc); const params: CompletionParams = { textDocument: { uri: doc.uri }, position: { line: 0, character: content.length }, }; const result = await server.onCompletion(params); expect(result).toBeDefined(); expect(result.items).toBeDefined(); expect(result.items.length).toBeGreaterThan(0); const pathItem = findPathCompletion(result); expect(pathItem).toBeDefined(); }); it('should complete echo $P to echo $PATH', async () => { const content = 'echo $P'; const doc = createFakeLspDocument('test.fish', content); analyzer.analyze(doc); const params: CompletionParams = { textDocument: { uri: doc.uri }, position: { line: 0, character: content.length }, }; const result = await server.onCompletion(params); expect(result).toBeDefined(); expect(result.items).toBeDefined(); expect(result.items.length).toBeGreaterThan(0); const pathItem = findPathCompletion(result); expect(pathItem).toBeDefined(); }); it('should complete echo $$$P to echo $$$PATH', async () => { const content = 'echo $$$P'; const doc = createFakeLspDocument('test.fish', content); analyzer.analyze(doc); const params: CompletionParams = { textDocument: { uri: doc.uri }, position: { line: 0, character: content.length }, }; const result = await server.onCompletion(params); expect(result).toBeDefined(); expect(result.items).toBeDefined(); expect(result.items.length).toBeGreaterThan(0); const pathItem = findPathCompletion(result); expect(pathItem).toBeDefined(); }); }); describe('Variable completion edge cases', () => { it('should handle quoted variable completion: echo "$P', async () => { const content = 'echo "$P'; const doc = createFakeLspDocument('test.fish', content); analyzer.analyze(doc); const params: CompletionParams = { textDocument: { uri: doc.uri }, position: { line: 0, character: content.length }, }; const result = await server.onCompletion(params); expect(result).toBeDefined(); expect(result.items).toBeDefined(); expect(result.items.length).toBeGreaterThan(0); const pathItem = findPathCompletion(result); expect(pathItem).toBeDefined(); }); it('should handle multiline completions', async () => { const content = 'if test\n echo $P'; const doc = createFakeLspDocument('test.fish', content); analyzer.analyze(doc); const params: CompletionParams = { textDocument: { uri: doc.uri }, position: { line: 1, character: 9 }, // At the end of $P in second line }; const result = await server.onCompletion(params); expect(result).toBeDefined(); expect(result.items).toBeDefined(); expect(result.items.length).toBeGreaterThan(0); const pathItem = findPathCompletion(result); expect(pathItem).toBeDefined(); }); }); describe('Completion triggers variable expansion mode', () => { it('should properly detect variable expansion context patterns', async () => { const testCases = [ { content: 'echo $$PA', pos: { line: 0, character: 9 } }, { content: 'echo $', pos: { line: 0, character: 6 } }, { content: 'echo $P', pos: { line: 0, character: 7 } }, { content: 'echo $$$P', pos: { line: 0, character: 9 } }, ]; for (const testCase of testCases) { const doc = createFakeLspDocument('test.fish', testCase.content); analyzer.analyze(doc); const result = await server.onCompletion({ textDocument: { uri: doc.uri }, position: testCase.pos, }); // All cases should return variable completions expect(result.items.length).toBeGreaterThan(0); // Should contain variables, not just commands const hasVariables = result.items.some(item => item.kind === 6); // SymbolKind.Variable expect(hasVariables).toBe(true); } }); it('$XDG_', async () => { const testCases = [ { content: 'echo $X', pos: { line: 0, character: 7 } }, { content: 'echo $XDG', pos: { line: 0, character: 9 } }, { content: 'echo $XDG_', pos: { line: 0, character: 10 } }, ]; for (const testCase of testCases) { const doc = createFakeLspDocument('test.fish', testCase.content); analyzer.analyze(doc); const result = await server.onCompletion({ textDocument: { uri: doc.uri }, position: testCase.pos, }); expect(result.items.length).toBeGreaterThan(0); // Should contain variables, not just commands const hasVariables = result.items.some(item => item.kind === 6); // SymbolKind.Variable expect(hasVariables).toBe(true); const variableCompletions = result.items.filter((item: any) => { return item.kind === 6; }); for (const variable of variableCompletions) { if (!variable.label.startsWith('XDG_')) continue; const textEdit = variable.textEdit as { newText: string; range: Range; }; expect(textEdit.range.start.character).toBe(6); logCompletionItem(variable); } } }); }); }); ================================================ FILE: tests/conditional-execution-diagnostics.test.ts ================================================ import { analyzer, Analyzer } from '../src/analyze'; import { initializeParser } from '../src/parser'; import * as Parser from 'web-tree-sitter'; import { workspaceManager } from '../src/utils/workspace-manager'; // import { LspDocument } from '../src/document'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { ErrorCodes } from '../src/diagnostics/error-codes'; import { createFakeLspDocument } from './helpers'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { config } from '../src/config'; let parser: Parser; describe('Conditional Execution Diagnostics', () => { beforeEach(async () => { await setupProcessEnvExecFile(); parser = await initializeParser(); await Analyzer.initialize(); config.fish_lsp_strict_conditional_command_warnings = true; }); afterEach(() => { parser.delete(); workspaceManager.clear(); config.fish_lsp_strict_conditional_command_warnings = false; }); describe('Basic conditional execution chains', () => { it('should report diagnostic for set command without -q in && chain', async () => { const code = 'set a && set -q b'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); expect(conditionalDiagnostics[0]?.range.start.character).toBe(0); // Points to first 'set' }); it('should report diagnostic for set command without -q in || chain', async () => { const code = 'set a || set -q b'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); expect(conditionalDiagnostics[0]?.range.start.character).toBe(0); // Points to first 'set' }); it('should not report diagnostic for set -q command in && chain', async () => { const code = 'set -q a && set b'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); it('should not report diagnostic for second command in chain', async () => { const code = 'set -q a && set b'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); // Should not report diagnostic for the second 'set b' command const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); }); describe('If statement conditionals', () => { it('should report diagnostic for set command without -q in if condition', async () => { const code = `if set bar echo bar is set end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); expect(conditionalDiagnostics[0]?.range.start.character).toBe(3); // Points to 'set' }); it('should not report diagnostic for set -q command in if condition', async () => { const code = `if set -q foo echo foo is set end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); it('should report diagnostic for set command without -q in else if condition', async () => { const code = `if set -q foo echo foo is set else if set bar echo bar is set end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); expect(conditionalDiagnostics[0]?.range.start.line).toBe(2); // else if line }); }); describe('Complex nested scenarios', () => { it('should handle the example from requirements correctly', async () => { const code = `if set -ql foo_1 # no diagnostic set -l foo_2 # no diagnostic set foo_3 # no diagnostic set -gx foo_4 # no diagnostic set -q foo_4 && set -f foo_4 $foo_1 || set -f foo_4 $foo_2 # no diagnostic else if set bar_1 # diagnostic set bar_2 # no diagnostic command -q $foo_1 || command $foo_2 # no diagnostic else if set baz_1 || set -ql baz_2 # diagnostic on 'set' command for baz_1 if set -q qux_1 # no diagnostic end end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(2); // Should flag 'set bar_1' and 'set baz_1' const line5Diagnostic = conditionalDiagnostics.find(d => d.range.start.line === 5); const line8Diagnostic = conditionalDiagnostics.find(d => d.range.start.line === 8); expect(line5Diagnostic).toBeDefined(); expect(line8Diagnostic).toBeDefined(); }); it('should not report diagnostic for chained commands where first has -q', async () => { const code = 'set -q foo_4 && set -f foo_4 $foo_1 || set -f foo_4 $foo_2'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); it('should not report diagnostic for commands inside if body (only conditions are checked)', async () => { const code = `if set -q foo set bar # should not be flagged - inside body, not a condition set baz # should not be flagged - inside body, not a condition end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); }); describe('Command types that should be checked', () => { it('should check command without -q flag', async () => { const code = 'command ls && echo found'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); }); it('should check type without -q flag', async () => { const code = 'type ls && echo found'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); }); it('should check string without -q flag', async () => { const code = 'string match "pattern" $var && echo found'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); }); it('should not check unrelated commands', async () => { const code = 'echo hello && echo world'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); }); describe('Edge cases', () => { it('should not report diagnostic for set commands with command substitution', async () => { const code = 'set a (some_command) && echo done'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); it('should handle long chains correctly - only first command checked', async () => { const code = 'set a && set -q b && set c && set d'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); expect(conditionalDiagnostics[0]?.range.start.character).toBe(0); // Only first 'set a' }); it('should handle mixed operators', async () => { const code = 'set a || set -q b && set c'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); expect(conditionalDiagnostics[0]?.range.start.character).toBe(0); // Points to first 'set a' }); }); describe('Alternative quiet flags', () => { it('should accept --quiet flag', async () => { const code = 'set --quiet a && echo found'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); it('should accept --query flag for applicable commands', async () => { const code = 'type --query ls && echo found'; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(0); }); }); describe('Nested conditional scenarios', () => { it('should flag commands in nested if statements within conditions', async () => { const code = `if set -q PATH if set YARN_PATH # should be flagged - first command in nested if condition set -a PATH $YARN_PATH || set -a PATH $NODE_PATH # no diagnostic - first has -a not -q, second is not first end end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(1); // Should flag the nested 'set YARN_PATH' command const nestedDiagnostic = conditionalDiagnostics.find(d => d.range.start.line === 1); expect(nestedDiagnostic).toBeDefined(); }); it('should handle deeply nested conditional chains', async () => { const code = `if set -q PATH if set -q NODE_PATH if set YARN_PATH # should be flagged echo "found yarn" else if set NPM_PATH # should be flagged echo "found npm" end end end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(2); }); it('should not flag commands in if bodies that are not conditions', async () => { const code = `if set -q foo set bar # should NOT be flagged - this is in the body, not the condition if set baz # should be flagged - this is a condition set qux # should NOT be flagged - this is in the body end else if set quux # should be flagged - this is a condition set corge # should NOT be flagged - this is in the body end`; const document = createFakeLspDocument('test.fish', code); const root = parser.parse(code).rootNode; const diagnostics = await getDiagnosticsAsync(root, document); const conditionalDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.missingQuietOption); expect(conditionalDiagnostics).toHaveLength(2); // Only 'set baz' and 'set quux' }); }); }); ================================================ FILE: tests/definition-location.test.ts ================================================ import * as os from 'os'; import * as Parser from 'web-tree-sitter'; import { analyzer, Analyzer } from '../src/analyze'; import { initializeParser } from '../src/parser'; import { execCommandLocations } from '../src/utils/exec'; // import { currentWorkspace, findCurrentWorkspace, workspaces } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { createFakeLspDocument, createTestWorkspace, setLogger } from './helpers'; import { getRange } from '../src/utils/tree-sitter'; import { isMatchingOption, Option } from '../src/parsing/options'; import { isCompletionCommandDefinition, isCompletionDefinitionWithName, isCompletionSymbol } from '../src/parsing/complete'; import { isCommandWithName, isOption } from '../src/utils/node-types'; import { isArgparseVariableDefinitionName } from '../src/parsing/argparse'; import { getReferences } from '../src/references'; let parser: Parser; // let currentWorkspace: CurrentWorkspace = new CurrentWorkspace(); describe('find definition locations of symbols', () => { setLogger(); beforeEach(async () => { parser = await initializeParser(); await Analyzer.initialize(); }); afterEach(() => { parser.delete(); workspaceManager.clear(); }); describe('find analyzed symbol location', () => { it('should find symbol location', async () => { const documents = createTestWorkspace( analyzer, { path: 'functions/test.fish', text: [ 'function test', ' echo "hello"', 'end', ], }, { path: 'functions/test2.fish', text: [ 'function test2', ' echo "hello"', 'end', ], }, ); const doc = documents.at(0)!; const symbols = analyzer.getFlatDocumentSymbols(doc.uri); expect(symbols).toHaveLength(2); }); it('should find test location', () => { const documents = createTestWorkspace( analyzer, { path: 'functions/test.fish', text: [ 'function test', ' echo "hello"', 'end', ], }, { path: 'functions/test2.fish', text: [ 'function test2', ' echo "hello"', 'end', ], }, { path: 'functions/test3.fish', text: [ 'function test3', ' test', 'end', ], }, ); expect(documents).toHaveLength(3); const doc = documents.at(-1)!; const nodes = analyzer.getNodes(doc.uri); const node = nodes.find((n) => n.type === 'command' && n.text === 'test')!; // console.log('node', { // text: node?.text, // type: node?.type, // start: getRange(node).start, // end: getRange(node).end, // }); const defLocations = analyzer.getDefinitionLocation(doc, getRange(node).start); expect(defLocations).toHaveLength(1); const def = defLocations.at(0)!; // console.log('def', { // uri: def?.uri, // range: def?.range, // }); expect(def.uri).toBe(documents.at(0)!.uri); expect(def.range.start.line).toBe(0); expect(def.range.start.character).toBe(9); expect(def.range.end.line).toBe(0); expect(def.range.end.character).toBe(13); }); it('should find completion location', () => { const documents = createTestWorkspace( analyzer, { path: 'functions/test.fish', text: [ 'function test', ' argparse --stop-nonopt h/help name= q/quiet v/version y/yes n/no -- $argv', ' or return', ' if set -lq _flag_help', ' echo "help_msg"', ' end', ' if set -lq _flag_name && test -n "$_flag_name"', ' echo "$_flag_name"', ' end', ' if set -lq _flag_quiet', ' echo "quiet"', ' end', ' if set -lq _flag_version', ' echo "1.0.0"', ' end', ' if set -lq _flag_yes', ' echo "yes"', ' end', ' if set -lq _flag_no', ' echo "no"', ' end', ' echo $argv', 'end', ], }, { path: 'completions/test.fish', text: [ 'complete -c test -s h -l help', 'complete -c test -l name', 'complete -c test -s q -l quiet', 'complete -c test -s v -l version', 'complete -c test -s y -l yes', 'complete -c test -s n -l no', ], }, ); expect(documents).toHaveLength(2); const functionDoc = documents.at(0)!; const completionDoc = documents.at(1)!; expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); const functionSymbols = analyzer.getFlatDocumentSymbols(functionDoc.uri); expect(functionSymbols).toHaveLength(13); // expect(completionSymbols).toHaveLength(6); const searchNode = analyzer.getNodes(completionDoc.uri).find(n => isCompletionSymbol(n) && n.text === 'help'); const result = analyzer.getDefinitionLocation(completionDoc, getRange(searchNode!).start); const resultUri = result[0]?.uri; // console.log({ // uri: result[0]?.uri, // range: result[0]?.range, // }) if (!resultUri) { console.log('resultUri is undefined'); fail(); return; } expect(result).toHaveLength(1); expect(resultUri).toBe(functionDoc.uri); }); it.skip('should find --flag-name location', () => { const documents = createTestWorkspace( analyzer, { path: 'functions/test.fish', text: [ 'function test', ' argparse --stop-nonopt h/help name= q/quiet v/version y/yes n/no -- $argv', ' or return', ' if set -lq _flag_help', ' echo "help_msg"', ' end', ' if set -lq _flag_name && test -n "$_flag_name"', ' echo "$_flag_name"', ' end', ' if set -lq _flag_quiet', ' echo "quiet"', ' end', ' if set -lq _flag_version', ' echo "1.0.0"', ' end', ' if set -lq _flag_yes', ' echo "yes"', ' end', ' if set -lq _flag_no', ' echo "no"', ' end', ' echo $argv', 'end', ], }, { path: 'completions/test.fish', text: [ 'complete -c test -s h -l help', 'complete -c test -l name', 'complete -c test -s q -l quiet', 'complete -c test -s v -l version', 'complete -c test -s y -l yes', 'complete -c test -s n -l no', ], }, { path: 'conf.d/test.fish', text: [ 'function __test', ' test --yes', 'end', ], }, ); expect(documents).toHaveLength(3); const functionDoc = documents.at(0)!; const completionDoc = documents.at(1)!; const confdDoc = documents.at(2)!; expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); expect(confdDoc).toBeDefined(); const nodeAtPoint = analyzer.nodeAtPoint(confdDoc.uri, 1, 10); const completionNode = analyzer.findNode((n, doc) => { if (doc?.uri === completionDoc.uri && n.parent && isCompletionCommandDefinition(n.parent)) { return n.text === 'yes'; } return false; }); const funcNode = analyzer.findNode((n, doc) => { if (doc?.uri === functionDoc.uri && isArgparseVariableDefinitionName(n) && n.text.includes('yes')) { return true; } return false; }); console.log('testNode', { uri: confdDoc.uri, line: 1, character: 10, node: nodeAtPoint?.type, text: nodeAtPoint?.text, }, 'completionNode', { uri: completionDoc.uri, line: completionNode!.startPosition.row, character: completionNode!.startPosition.column, node: completionNode!.type, text: completionNode!.text, }, 'funcNode', { uri: functionDoc.uri, line: funcNode!.startPosition.row, character: funcNode!.startPosition.column, node: funcNode!.type, text: funcNode!.text, }, ); if (nodeAtPoint && isOption(nodeAtPoint)) { const result = getReferences(confdDoc, getRange(nodeAtPoint).start); result.forEach(loc => { console.log('location', { uri: loc.uri, range: loc.range.start, }); }); expect(result).toHaveLength(4); const symbol = analyzer.findSymbol((s) => { if (s.parent && s.fishKind === 'ARGPARSE') { return nodeAtPoint.parent?.firstNamedChild?.text === s.parent?.name && s.parent?.isGlobal() && nodeAtPoint.text.startsWith(s.argparseFlag); } return false; }); // console.log({ // symbol: symbol?.name, // uri: symbol?.uri, // range: symbol?.selectionRange, // }); if (!symbol) { console.log('symbol not found'); return; } const parentName = symbol.parent?.name || ''; const matchingNodes = analyzer.findNodes((n, document) => { // complete -c parentName -s ... -l flag-name if ( isCompletionDefinitionWithName(n, parentName, document!) && n.text === symbol.argparseFlagName ) { return true; } // parentName --flag-name if ( n.parent && isCommandWithName(n.parent, parentName) && isOption(n) && isMatchingOption(n, Option.fromRaw(symbol?.argparseFlag)) ) { return true; } // _flag_name in scope if ( document!.uri === symbol.uri && symbol.scopeContainsNode(n) && n.text === symbol.name ) { return true; } return false; }); for (const { uri, nodes } of matchingNodes) { console.log(`nodes ${uri}`); console.log(nodes.map(n => n.text)); } // const completionNodes = getGlobalArgparseLocations(analyzer, functionDoc, symbol); // for (const { uri, range } of completionNodes) { // console.log(`completion ${uri}`); // console.log(range); // } expect(true).toBeTruthy(); } // const functionSymbols = analyzer.getFlatDocumentSymbols(functionDoc.uri); // const completionSymbols = analyzer.getFlatCompletionSymbols(completionDoc.uri); // const confdNodes = analyzer.findNodes((n) => { // if (n.parent && isCommandWithName(n.parent, 'test') && isOption(n) && isMatchingOption(n, Option.create('-y', '--yes'))) { // return true; // } // return false; // }); // for (const { uri, nodes } of confdNodes) { // console.log(`confd ${uri}`); // console.log(nodes.map(n => n.text)); // } }); }); describe.skip('update currentWorkspace.current workspace', () => { it('should update currentWorkspace', async () => { [ createFakeLspDocument('functions/test.fish', 'function test', ' echo "hello"', 'end', ), createFakeLspDocument('functions/test2.fish', 'function test2', ' echo "hello"', 'end', ), ].forEach(async (doc) => { const newWorkspace = workspaceManager.findContainingWorkspace(doc.uri); expect(newWorkspace).toBeDefined(); workspaceManager.handleOpenDocument(doc); }); expect(workspaceManager.current).toBeDefined(); expect(workspaceManager.current?.path).toBe(`${os.homedir()}/.config/fish`); expect(workspaceManager.current?.getUris()).toHaveLength(1); }); }); describe('finding global command\'s location path', () => { it('`fish_add_path` -> valid', async () => { const cmd = 'fish_add_path'; const locations = execCommandLocations(cmd); expect(locations.length).toBeGreaterThanOrEqual(1); }); it('`source` -> INVALID', async () => { const cmd = 'source'; const locations = execCommandLocations(cmd); expect(locations).toHaveLength(0); }); it('`alias` -> valid', () => { const cmd = 'alias'; const locations = execCommandLocations(cmd); expect(locations.length).toBeGreaterThanOrEqual(1); const { uri, path } = locations.at(0)!; // console.log({ uri, path }) expect(uri).toBeDefined(); expect(path).toBeDefined(); expect(path.endsWith('alias.fish')).toBeTruthy(); expect(uri.endsWith('alias.fish')).toBeTruthy(); }); }); }); ================================================ FILE: tests/diagnostics-with-missing-completions.test.ts ================================================ import * as os from 'os'; import * as fs from 'fs'; import { findAllMissingArgparseFlags } from '../src/diagnostics/missing-completions'; import { LspDocument } from '../src/document'; import { flattenNested } from '../src/utils/flatten'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { createTestWorkspace, setLogger, TestLspDocument, fail } from './helpers'; import { SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from '../src/parser'; import { Analyzer, analyzer } from '../src/analyze'; import { WorkspaceManager, workspaceManager } from '../src/utils/workspace-manager'; import { FishUriWorkspace, Workspace } from '../src/utils/workspace'; import { logger } from '../src/logger'; import { getGroupedCompletionSymbolsAsArgparse, groupCompletionSymbolsTogether } from '../src/parsing/complete'; import { config } from '../src/config'; import { ErrorCodes } from '../src/diagnostics/error-codes'; let documents: LspDocument[] = []; describe('diagnostics with missing completions', () => { setLogger(); beforeAll(async () => { await Analyzer.initialize(); config.fish_lsp_diagnostic_disable_error_codes = [ErrorCodes.requireAutloadedFunctionHasDescription]; }); describe('analyze workspace 1: `function`', () => { const inputDocs: TestLspDocument[] = [ { path: 'functions/fish_function.fish', text: [ 'function fish_function', ' argparse a/arg1 -- $argv', ' or return', ' set -l hello "hello"', ' set -l world "world"', ' echo "$hello, $world!"', 'end', ].join('\n'), }, { path: 'completions/fish_function.fish', text: [ 'complete -c fish_function -s a -l arg1 -d "Argument 1"', 'complete -c fish_function -l arg2 -d "Argument 2"', 'complete -c fish_function -l arg3 -d "Argument 3"', ].join('\n'), }, ]; beforeEach(async () => { documents = createTestWorkspace(analyzer, ...inputDocs); documents.forEach(doc => { const path = doc.getFilePath(); fs.writeFileSync(path, doc.getText(), 'utf-8'); }); const testWorkspace = await Workspace.create('__fish_config_dir', `file://${os.homedir()}/.config/fish`, `${os.homedir()}/.config/fish`); documents.forEach(doc => { testWorkspace.addUri(doc.uri); analyzer.analyze(doc); logger.log(`Opened document: ${doc.path}`); }); workspaceManager.add(testWorkspace); workspaceManager.setCurrent(testWorkspace); await workspaceManager.analyzePendingDocuments(); // logger.debug(workspaceManager.all.map(ws => ({ uri: ws.uri, uris: ws.getUris(), analyzed: ws.uris.indexed }))); }); afterEach(() => { documents.forEach(doc => { const path = doc.getFilePath(); if (fs.existsSync(path)) { fs.unlinkSync(path); } }); }); it('should analyze a simple function definition', async () => { const functionDoc = documents.find(doc => doc.path.endsWith('functions/fish_function.fish'))!; const completionDoc = documents.find(doc => doc.path.endsWith('completions/fish_function.fish'))!; if (!functionDoc || !completionDoc) fail(); expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); const functionCached = analyzer.analyze(functionDoc); const completionCached = analyzer.analyze(completionDoc); expect(functionCached).toBeDefined(); expect(completionCached).toBeDefined(); const diagnostics = await getDiagnosticsAsync(functionCached.root!, functionDoc); expect(diagnostics.length).toBe(2); const flatFuncSymbols = flattenNested(...functionCached.documentSymbols).filter(s => s.isFunction() && s.isGlobal()); const flatAutoloadedSymbols = flattenNested(...flatFuncSymbols); logger.debug({ flatFuncSymbols: flatFuncSymbols.map(s => s.name), flatAutoloadedSymbols: flatAutoloadedSymbols.map(s => s.name), }); // const missingCompletions = findAllMissingArgparseFlags(functionDoc, flatFuncSymbols); const completionSymbols = analyzer.getFlatCompletionSymbols(completionDoc.uri).filter(s => s.isNonEmpty()); const completionGroups = groupCompletionSymbolsTogether(...completionSymbols); const missingCompletions = getGroupedCompletionSymbolsAsArgparse(completionGroups, flatAutoloadedSymbols); // expect(missingCompletions).toEqual(); // logger.log({ // missingCompletions: missingCompletions.map(cGroup => { // return { // items: cGroup.map(c => ({ // name: c.text, // description: c.description, // flag: c.toFlag(), // usage: c.toUsage(), // })), // argparse: cGroup.map(c => c.toArgparseOpt()).join('/'), // }; // }) // }); const result = findAllMissingArgparseFlags(functionDoc); logger.log({ result: result.map(r => ({ code: r.code, message: r.message, range: [r.range.start.line, r.range.start.character, r.range.end.line, r.range.end.character].join(', '), node: r.data.node.text, })), }); }); }); }); ================================================ FILE: tests/diagnostics.test.ts ================================================ import * as os from 'os'; import { homedir } from 'os'; import * as Parser from 'web-tree-sitter'; import { SyntaxNode, Tree } from 'web-tree-sitter'; import { findChildNodes, getChildNodes, getNodeAtRange, nodesGen } from '../src/utils/tree-sitter'; import { Diagnostic, DiagnosticSeverity, InitializedParams, InitializeParams, TextDocumentItem } from 'vscode-languageserver'; import { initializeParser } from '../src/parser'; import { ErrorCodes } from '../src/diagnostics/error-codes'; // import { fishNoExecuteDiagnostic } from '../src/diagnostics/no-execute-diagnostic'; import { isCommand, isComment, isDefinitionName } from '../src/utils/node-types'; // import { ScopeStack, isReference } from '../src/diagnostics/scope'; import { findErrorCause, isExtraEnd, isZeroIndex, isSingleQuoteVariableExpansion, isAlias, isUniversalDefinition, isSourceFilename, isTestCommandVariableExpansionWithoutString, isConditionalWithoutQuietCommand, isVariableDefinitionWithExpansionCharacter, isArgparseWithoutEndStdin } from '../src/diagnostics/node-types'; import { LspDocument } from '../src/document'; import { createFakeLspDocument, setLogger, fail, createMockConnection } from './helpers'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { DiagnosticComment, DiagnosticCommentsHandler, isDiagnosticComment, parseDiagnosticComment } from '../src/diagnostics/comments-handler'; import { withTempFishFile } from './temp'; import { workspaceManager } from '../src/utils/workspace-manager'; // import { Option } from '../src/parsing/options'; import { getNoExecuteDiagnostics } from '../src/diagnostics/no-execute-diagnostic'; import { analyzer, Analyzer } from '../src/analyze'; import { config } from '../src/config'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { logger } from '../src/logger'; import { testOpenDocument } from './document-test-helpers'; import { PrebuiltDocumentationMap } from '../src/utils/snippets'; import { CompletionItemMap } from '../src/utils/completion/startup-cache'; import { Server } from 'http'; import FishServer from '../src/server'; import { connection, startServer } from '../src/utils/startup'; import TestWorkspace from './test-workspace-utils'; import { FishSymbol } from '../src/parsing/symbol'; // import { isFunctionDefinitionName, isFunctionVariableDefinitionName } from '../src/parsing/function'; // import TestWorkspace from './test-workspace-utils'; // import { isArgparseVariableDefinitionName } from '../src/parsing/argparse'; // import { SetParser, AliasParser, ArgparseParser, CompleteParser, ReadParser, ForParser, FunctionParser, ExportParser } from '../src/parsing/barrel'; let parser: Parser; let diagnostics: Diagnostic[] = []; let output: SyntaxNode[] = []; let input: string = ''; setLogger( async () => { parser = await initializeParser(); diagnostics = []; input = ''; output = []; // Reset config to avoid test pollution config.fish_lsp_diagnostic_disable_error_codes = []; // Disable expensive unknown command check for unit tests config.fish_lsp_diagnostic_disable_error_codes.push(ErrorCodes.unknownCommand); }, async () => { parser.reset(); // Reset config after each test config.fish_lsp_diagnostic_disable_error_codes = []; }, ); function fishTextDocumentItem(uri: string, text: string): LspDocument { return new LspDocument({ uri: `file://${homedir()}/.config/fish/${uri}`, languageId: 'fish', version: 1, text, } as TextDocumentItem); } function severityStr(severity: DiagnosticSeverity | undefined) { switch (severity) { case DiagnosticSeverity.Error: return 'Error'; case DiagnosticSeverity.Warning: return 'Warning'; case DiagnosticSeverity.Information: return 'Information'; case DiagnosticSeverity.Hint: return 'Hint'; default: return 'Unknown'; } } function logDiagnostics(diagnostic: Diagnostic, root: SyntaxNode) { console.log('-'.repeat(80)); // console.log(`entire text: \n${root.text.slice(0, 20) + '...'}`); console.log(`diagnostic node: ${getNodeAtRange(root, diagnostic.range)?.text}`); console.log(`code: ${diagnostic.code!.toString()}`); // check uri for config.fish console.log(`message: ${diagnostic.message.toString()}`); // check uri for config.fish console.log(`severity: ${severityStr(diagnostic.severity)}`); // check uri for config.fish console.log(`range: ${JSON.stringify(diagnostic.range)}`); // check uri for config.fish console.log('-'.repeat(80)); } function mapDiagnostics(diagnostics: Diagnostic) { return { code: diagnostics.code, text: diagnostics.data.node.text, }; } function extractDiagnostics(tree: Tree) { const results: SyntaxNode[] = []; const cursor = tree.walk(); const visitNode = (node: Parser.SyntaxNode) => { if (node.isError) { results.push(node); } for (const child of node.children) { visitNode(child); } }; visitNode(tree.rootNode); return results; } describe('diagnostics test suite', () => { beforeAll(async () => { await Analyzer.initialize(); createMockConnection(); await FishServer.create(connection, {} as InitializeParams); logger.setSilent(); await setupProcessEnvExecFile(); }); beforeEach(async () => { await Analyzer.initialize(); logger.setSilent(); config.fish_lsp_diagnostic_disable_error_codes = [4008]; config.fish_lsp_strict_conditional_command_warnings = false; }); afterEach(() => { config.fish_lsp_diagnostic_disable_error_codes = []; config.fish_lsp_strict_conditional_command_warnings = false; }); afterAll(() => { config.fish_lsp_diagnostic_disable_error_codes = []; config.fish_lsp_strict_conditional_command_warnings = true; logger.setSilent(false); }); it('NODE_TEST: test finding specific error nodes', async () => { const inputs: string[] = [ [ 'echo "function error"', 'function foo', ' if test -n $argv', ' echo "empty"', ' ', 'end', ].join('\n'), [ 'echo "while error"', 'while true', ' echo "is true"', '', ].join('\n'), ['echo \'\' error\'', 'string match \''].join('\n'), ['echo \'\" error\'', 'string match -r "'].join('\n'), ['echo "\(" error', 'echo ('].join('\n'), ['echo \'\$\( error\'', 'echo $('].join('\n'), ['echo \'\{ error\'', 'echo {a,b'].join('\n'), ['echo \'\[ error\'', 'echo $argv['].join('\n'), ['echo \'\[ error\'', 'echo "$argv["'].join('\n'), ['echo \'\$\( error\'', 'echo "$("'].join('\n'), ]; const output: SyntaxNode[] = []; inputs.forEach((input, _) => { const tree = parser.parse(input); const result = extractDiagnostics(tree).pop()!; for (const r of nodesGen(result)) { if (!r.isError) continue; const errorNode = findErrorCause(r.children); // console.log(getChildNodes(r).map(n => n.text + ':::' + n.type)) // if (errorNode) console.log('------\nerrorNode', errorNode.text); if (!errorNode) fail(); output.push(errorNode!); } }); expect( output.map(n => n.text), ).toEqual( ['function', 'while', '"', '(', '(', '{', '[', '[', '('], ); }); it('NODE_TEST: check for extra end', async () => { input = [ 'function foo', ' echo "hi" ', 'end', 'end', ].join('\n'); const tree = parser.parse(input); for (const node of nodesGen(tree.rootNode)) { if (isExtraEnd(node)) { // console.log({type: node.type, text: node.text}); output.push(node); } } expect(output.length).toBe(1); }); it('NODE_TEST: 0 indexed array', async () => { input = 'echo $argv[0]'; const { rootNode } = parser.parse(input); for (const node of nodesGen(rootNode)) { if (isZeroIndex(node)) { // console.log({type: node.type, text: node.text}); output.push(node); } } expect(output.length).toBe(1); }); it('NODE_TEST: single quote includes variable expansion', async () => { input = 'echo \' $argv\''; const { rootNode } = parser.parse(input); for (const node of nodesGen(rootNode)) { if (isSingleQuoteVariableExpansion(node)) { // console.log({type: node.type, text: node.text}); // getChildNodes(node).forEach(n => console.log(n.text)) output.push(node); } } expect(output.length).toBe(1); }); it('NODE_TEST: isAlias definition', async () => { [ 'alias lst=\'ls --tree\'', 'alias lst \'ls --tree\'', 'alias lst "ls --tree"', ].forEach(input => { output = []; const { rootNode } = parser.parse(input); for (const node of nodesGen(rootNode)) { // console.log({type: node.type, text: node.text}); if (isAlias(node)) { output.push(node); } } expect(output.length).toBe(1); }); }); it('NODE_TEST: universal definition in script', async () => { [ 'set -Ux uvar \'SOME VAR\'', 'set --universal uvar \'SOME VAR\'', ].forEach(input => { const { rootNode } = parser.parse(input); for (const node of nodesGen(rootNode)) { // console.log({type: node.type, text: node.text}); if (isUniversalDefinition(node)) { output.push(node); } } }); expect(output.map(o => o.text)).toEqual([ '-Ux', '--universal', ]); }); it('NODE_TEST: find source file', async () => { [ 'source file_does_not_exist.fish', 'source', 'command cat file_does_not_exist.fish | source', ].forEach(input => { const { rootNode } = parser.parse(input); for (const node of getChildNodes(rootNode)) { if (isSourceFilename(node)) { output.push(node); // console.log({ type: node.type, text: node.text }); } // if (isCommandWithName(node, 'source')) { // console.log('SOURCE', { type: node.type, text: node.text, children: node.childCount}); // const filename = node.lastChild; // if (filename) console.log('FILENAME', { type: filename.type, text: filename.text }); // } } }); expect(output.map(o => o.text)).toEqual(['file_does_not_exist.fish']); }); it('NODE_TEST: isTestCommandVariableExpansionWithoutString \'test -n/-z "$var"\'', async () => { [ 'if test -n $arg0', 'if test -z "$arg1"', '[ -n $argv[2] ]', '[ -z "$arg3" ]', ].forEach(input => { const { rootNode } = parser.parse(input); for (const node of getChildNodes(rootNode)) { if (isTestCommandVariableExpansionWithoutString(node)) { // console.log({ type: node.type, text: node.text }); output.push(node); } } }); expect(output.map(o => o.text)).toEqual([ '$arg0', '$argv[2]', ]); }); it('NODE_TEST: silent flag', async () => { config.fish_lsp_strict_conditional_command_warnings = true; const outputWithFlag: SyntaxNode[] = []; const outputWithoutFlag: SyntaxNode[] = []; [ 'if command -q ls;end', 'if set -q argv; end', 'if true; echo hi; else if string match -q; echo p; end', 'if builtin -q set; end', 'if functions -aq ls; end', ].forEach((input, _index) => { const { rootNode } = parser.parse(input); for (const node of nodesGen(rootNode)) { if (isConditionalWithoutQuietCommand(node)) { outputWithFlag.push(node); } } }); [ 'if command ls;end', 'if set argv; end', 'if true; echo hi; else if string match; echo p; end', 'if builtin set; end', 'if functions ls; end', ['if test -n "$argv"', ' echo yes', 'else if test -z "$argv"', ' set -Ux variable a', 'end', ].join('\n'), ].forEach((input, _index) => { const { rootNode } = parser.parse(input); for (const node of nodesGen(rootNode)) { // if (index === 5 && node.type === 'if_statement') { // console.log({node: node.toString()}); // const condition = node.namedChildren.find(child => child.type === 'condition') // console.log('condi', node.childrenForFieldName('condition').map(c => c.text)); // console.log(node.namedChildren.map(c => c.type + ':' + c.text )); // console.log({ text: condition?.text, type: condition?.type, gType: condition?.grammarType }); // } // if (node.type === 'condition') { // } if (isConditionalWithoutQuietCommand(node)) { outputWithoutFlag.push(node); } } }); expect(outputWithFlag.length).toBe(0); expect(outputWithoutFlag.length).toBe(5); }); it('NODE_TEST: `if set -q var_name` vs `if set -q $var_name`', async () => { [ 'if set -q $variable_1; echo bad; end', 'if set -q variable_2; echo good; end', 'set $variable_3 (echo "a b c d e f $argv[2]") ', 'set $variable_4 $PATH', ].forEach(input => { const { rootNode } = parser.parse(input); for (const node of getChildNodes(rootNode)) { if (isVariableDefinitionWithExpansionCharacter(node)) { // console.log({ type: node.type, text: node.text, p: node.parent?.text || 'null' }); output.push(node); } } }); expect(output.map(o => o.text)).toEqual([ '$variable_3', '$variable_4', ]); }); it('NODE_TEST: conditional', async () => { config.fish_lsp_strict_conditional_command_warnings = false; type ConditionalOutput = { idx: number; node: string; }; const _output: ConditionalOutput[] = []; const testInputs = [ 'if set -q var || set -l bad_1; echo "var is set"; end;', 'if set -q var; or set -l bad_2; echo "var is set"; end;', 'if set fishpath (which fish); echo "$fishpath is set"; end;', 'if not string match -q -- $PNPM_HOME $PATH; set -gx PATH "$PNPM_HOME" $PATH; end;', ` if string match -q -- $PNPM_HOME $PATH \\ or set -q _flag_a set -gx PATH "$PNPM_HOME" $PATH end`, ` if set -xq __flag || set fishdir (command -v fish) echo fishdir: $fishdir end if set -qx __flag || set fishdir (command -v fish) echo fishdir: $fishdir else if set -q __flag \\ || set -q fishdir (command -v fish) echo fishdir: $fishdir end if set fishdir (status fish-path | string match -vr /bin/) echo fishdir: $fishdir end if functions -q fish_prompt echo fish_prompt end if command -q fish (status fish-path) echo fish: $fish end if builtin --query echo echo 'echo' end if type --all --query ls || functions -q ls || command -aq ls echo 'ls' end awk `]; for (let idx = 0; idx < testInputs.length; idx++) { const input = testInputs[idx]!; const { root, document } = analyzer.ensureCachedDocument(createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input)); if (!root || !document) continue; // completion diagnostics = await getDiagnosticsAsync(root, document); console.log(`---- input ${idx} ----`); diagnostics.forEach(d => logDiagnostics(d, root)); console.log({ idx: idx, diagnostics: diagnostics.map(d => ({ text: document.getText(d.range), code: d.code, mes: d.message, node: d.data.node.parent.text, })), }); _output.push(...diagnostics.map((d) => ({ node: d.data.node.parent.text, idx: idx }))); } console.log(_output); expect(_output).toEqual([ { idx: 0, node: 'set -l bad_1', }, { idx: 1, node: 'set -l bad_2', }, ]); }); /** * TODO: * Improve references usage for autoloaded functions, and other scopes */ it('NODE_TEST: unused local definition', async () => { const input = [ '# input 1', 'function foo', ' echo "inside foo" ', 'end', 'set --local variable_1 a', 'set --local variable_2 b', 'set --global variable_3 c', ].join('\n'); const { root, document } = analyzer.ensureCachedDocument(createFakeLspDocument('file:///tmp/test-1.fish', input)); if (!root) return; const defs = nodesGen(root).filter(n => { return isDefinitionName(n); }); expect(defs.map(d => d.text).toArray()).toEqual([ 'foo', 'variable_1', 'variable_2', 'variable_3', ]); const result = await getDiagnosticsAsync(root, document); expect(result.length).toBe(3); }); it('VALIDATE: missing end', async () => { [ 'echo "', 'echo \'', 'echo {a,b,c', 'echo $argv[', 'echo (', 'echo $(', ].forEach(async (input, idx) => { analyzer.ensureCachedDocument(createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input)); const { rootNode } = parser.parse(input); const doc = createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input); const result = await getDiagnosticsAsync(rootNode, doc, undefined, 1); expect(result.length).toBe(1); }); }); it('VALIDATE: extra end', async () => { // Disable unused local definition check for this test const savedDisabled = [...config.fish_lsp_diagnostic_disable_error_codes]; config.fish_lsp_diagnostic_disable_error_codes = [ErrorCodes.unknownCommand, ErrorCodes.unusedLocalDefinition]; [ 'for i in (seq 1 10); end; end', 'function foo; echo hi; end; end', ].forEach(async (input, idx) => { const doc = createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input); const cached = analyzer.ensureCachedDocument(doc).ensureParsed(); const { root } = cached; const result = await getDiagnosticsAsync(root, doc); expect(result.length).toBe(1); }); // Restore config config.fish_lsp_diagnostic_disable_error_codes = savedDisabled; }); it('VALIDATE: zero index', async () => { [ 'echo $argv[0]', ].forEach(async (input, idx) => { analyzer.ensureCachedDocument(createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input)); const { rootNode } = parser.parse(input); const doc = createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input); const result = await getDiagnosticsAsync(rootNode, doc); expect(result.map(r => r.code)).toContain(ErrorCodes.zeroIndexedArray); expect(result.length).toBe(1); }); }); it.skip('VALIDATE: isSingleQuoteVariableExpansion', () => { [ 'echo \'$argv[1]\'; echo \'\\$argv[1]\'', ].forEach(async (input, idx) => { const { rootNode } = parser.parse(input); const doc = createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input); analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(rootNode, doc); expect(result.length).toBe(1); }); }); it('VALIDATE: isAlias', async () => { // config.fish_lsp_diagnostic_disable_error_codes.push(ErrorCodes.usedAlias); [ 'alias foo=\'fish_opt\'\nfoo', ].forEach(async (input, idx) => { const { rootNode } = parser.parse(input); const doc = createFakeLspDocument(`file:///tmp/test-${idx}.fish`, input); analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(rootNode, doc); result.forEach(r => logDiagnostics(r, rootNode)); expect(result.length).toBe(1); const diagnosticTypes = result.map(r => r.code); expect(diagnosticTypes).toContain(ErrorCodes.usedWrapperFunction); }); }); it('VALIDATE: isUniversal', async () => { [ 'set -U _foo abcdef', 'set -U _foo abcdef', ].forEach(async (input, idx) => { const { rootNode } = parser.parse(input); const uri = idx === 1 ? `file://${os.homedir()}/.config/fish/conf.d/test-1.fish` : `file:///tmp/test-${idx}.fish`; const doc = createFakeLspDocument(uri, input); analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(rootNode, doc); if (idx === 0) { expect(result.length).toBe(1); } else if (idx === 1) { expect(result.length).toBe(0); } }); }); it('VALIDATE: sourceFilename', async () => { [ 'source ~/.config/fish/__cconfig.fish', 'source (echo get-fish-config-file)', ].forEach(async (input, idx) => { const uri = idx === 1 ? `file://${os.homedir()}/.config/fish/conf.d/test-1.fish` : `file:///tmp/test/test-${idx}.fish`; const doc = createFakeLspDocument(uri, input); const { root, document } = analyzer.analyze(doc).ensureParsed(); // analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(root, document); if (idx === 0) { expect(result.length).toBe(1); } else if (idx === 1) { // logger.setSilent(false); // logger.log(doc.showTree()); expect(result.length).toBe(0); } }); }); it('VALIDATE: isTestCommandVariableExpansionWithoutString', async () => { [ 'test -n $argv', '[ -n $argv ]', '[ -z $argv[1] ]', ].forEach(async (input, idx) => { const { rootNode } = parser.parse(input); const uri = idx === 1 ? `file://${os.homedir()}/.config/fish/conf.d/test-1.fish` : `file:///tmp/test-${idx}.fish`; const doc = createFakeLspDocument(uri, input); analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(rootNode, doc); expect(result.length).toBe(1); }); }); it('VALIDATE: isConditionalWithoutQuietCommand', async () => { config.fish_lsp_strict_conditional_command_warnings = true; [ 'if string match -r \'a\' "$argv";end;', 'if set var;end;', ].forEach(async (input, idx) => { const { rootNode } = parser.parse(input); const uri = idx === 1 ? `file://${os.homedir()}/.config/fish/conf.d/test-1.fish` : `file:///tmp/test-${idx}.fish`; const doc = createFakeLspDocument(uri, input); analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(rootNode, doc); expect(result.length).toBe(1); }); }); it('VALIDATE: isVariableDefinitionWithExpansionCharacter', async () => { [ 'set $argv a b c', 'set $argv[1] a b c', ].forEach(async (input, idx) => { const { rootNode } = parser.parse(input); const uri = idx === 1 ? `file://${os.homedir()}/.config/fish/conf.d/test-1.fish` : `file:///tmp/test-${idx}.fish`; const doc = createFakeLspDocument(uri, input); analyzer.ensureCachedDocument(doc); const result = await getDiagnosticsAsync(rootNode, doc); expect(result.length).toBe(1); }); }); it('VALIDATE: isDiagnosticComment', async () => { const input = `echo 'now diagnostics are enabled' # @fish-lsp-disable echo '1 all diagnostics are disabled' # @fish-lsp-enable echo '2 now diagnostics are enabled again' # @fish-lsp-disable 2001 echo '3 only diagnostic error code 2001 is disabled' # @fish-lsp-enable 2001 echo '4 diagnostic 2001 is enabled again' # @fish-lsp-disable 1001 1002 1003 echo '5 only diagnostic error codes 1001 1002 1003 are disabled' # @fish-lsp-enable echo '6 enabled all diagnostics again' # @fish-lsp-disable 3003 3002 3001 echo '7 disabled 3003 3002 3001' # @fish-lsp-disable-next-line 2001 2002 echo '8 disable next line diagnostics for 2001 2002' echo '9 2001 and 2002 are enabled again' echo '10 3003 3002 3001 are still disabled'`; const { rootNode } = parser.parse(input); const doc = createFakeLspDocument('file:///tmp/test-1.fish', input); analyzer.ensureCachedDocument(doc); const lspDiagnosticComments: DiagnosticComment[] = findChildNodes(rootNode, n => isDiagnosticComment(n)) .map(parseDiagnosticComment) .filter(c => c !== null); const enabledDiagnostics = ErrorCodes.allErrorCodes; // need to disable config.fish_lsp_disabled_error_codes const handler = new DiagnosticCommentsHandler(); nodesGen(rootNode).forEach(node => { handler.handleNode(node); if (!isComment(node) && node.isNamed && isCommand(node)) { if (node.text.includes('1 all diagnostics are disabled')) { expect(handler.isCodeEnabled(1001)).toBe(false); expect(handler.isCodeEnabled(2001)).toBe(false); expect(handler.isCodeEnabled(3001)).toBe(false); } else if (node.text.includes('2 now diagnostics are enabled again')) { expect(handler.isCodeEnabled(1001)).toBe(true); } else if (node.text.includes('3 only diagnostic error code 2001 is disabled')) { expect(handler.isCodeEnabled(1001)).toBe(true); expect(handler.isCodeEnabled(2001)).toBe(false); expect(handler.isCodeEnabled(3001)).toBe(true); } else if (node.text.includes('4 diagnostic 2001 is enabled again')) { expect(handler.isCodeEnabled(2001)).toBe(true); expect(handler.isCodeEnabled(3001)).toBe(true); } else if (node.text.includes('5 only diagnostic error codes 1001 1002 1003 are disabled')) { expect(handler.isCodeEnabled(1001)).toBe(false); expect(handler.isCodeEnabled(1002)).toBe(false); expect(handler.isCodeEnabled(1003)).toBe(false); expect(handler.isCodeEnabled(2001)).toBe(true); expect(handler.isCodeEnabled(3001)).toBe(true); } else if (node.text.includes('6 enabled all diagnostics again')) { expect(handler.isCodeEnabled(1001)).toBe(true); expect(handler.isCodeEnabled(1002)).toBe(true); expect(handler.isCodeEnabled(1003)).toBe(true); expect(handler.isCodeEnabled(2001)).toBe(true); expect(handler.isCodeEnabled(3001)).toBe(true); } else if (node.text.includes('7 disabled 3003 3002 3001')) { expect(handler.isCodeEnabled(1001)).toBe(true); expect(handler.isCodeEnabled(1002)).toBe(true); expect(handler.isCodeEnabled(3001)).toBe(false); expect(handler.isCodeEnabled(3002)).toBe(false); expect(handler.isCodeEnabled(3003)).toBe(false); } else if (node.text.includes('8 disable next line diagnostics for 2001 2002')) { expect(handler.isCodeEnabled(1003)).toBe(true); expect(handler.isCodeEnabled(2001)).toBe(false); expect(handler.isCodeEnabled(2002)).toBe(false); expect(handler.isCodeEnabled(3001)).toBe(false); expect(handler.isCodeEnabled(3002)).toBe(false); expect(handler.isCodeEnabled(3003)).toBe(false); } else if (node.text.includes('9 2001 and 2002 are enabled again')) { expect(handler.isCodeEnabled(2001)).toBe(true); expect(handler.isCodeEnabled(2002)).toBe(true); expect(handler.isCodeEnabled(3001)).toBe(false); expect(handler.isCodeEnabled(3002)).toBe(false); expect(handler.isCodeEnabled(3003)).toBe(false); } else if (node.text.includes('10 3003 3002 3001 are still disabled')) { expect(handler.isCodeEnabled(1001)).toBe(true); expect(handler.isCodeEnabled(1002)).toBe(true); expect(handler.isCodeEnabled(1003)).toBe(true); expect(handler.isCodeEnabled(1004)).toBe(true); expect(handler.isCodeEnabled(2001)).toBe(true); expect(handler.isCodeEnabled(2002)).toBe(true); expect(handler.isCodeEnabled(2003)).toBe(true); expect(handler.isCodeEnabled(3001)).toBe(false); expect(handler.isCodeEnabled(3002)).toBe(false); expect(handler.isCodeEnabled(3003)).toBe(false); } } }); }); describe('NODE_TEST: find argparse', () => { it('find argparse', async () => { const input = ` function foo argparse l/long s/short -- $argv or return end`; const tree = parser.parse(input); const rootNode = tree.rootNode; analyzer.ensureCachedDocument(createFakeLspDocument('file:///tmp/test-argparse.fish', input)); for (const node of nodesGen(rootNode)) { if (isArgparseWithoutEndStdin(node)) { console.log(node.text); } } expect(true).toBe(true); }); }); describe.skip('CONDITIONAL EDGE CASES', () => { const testcases = [ // { // title: 'normal case, where both variables are in a if statement, so both should be silenced', // input: `if set -q var1 && set -q var2; echo 'var1 and var2 are set'; end`, // expected: [ // // ], // }, { shouldRun: true, title: '[CHAINED] updating a variable only when it exists 1', input: ` echo 'hello world' if set var_with_default_value && set var_with_default_value 'new_value' echo hi end set ovar && set ovar 'new_value' set uvar and set uvar 'new_value' `, expected: [ 'set var_with_default_value', 'set var_with_default_value \'new_value\'', 'set ovar', 'set uvar', ], }, { shouldRun: false, title: '[CHAINED] defining a variable only when it is not set', input: 'not set -q var_with_default_value && set var_with_default_value \'default_value\'', expected: [ 'var_with_default_value', ], }, { title: '[CHAINED], updating a variable or defining it w/ default value', input: 'set -q var_with_default_value && set var_with_default_value \'new_value\' || set var_with_default_value \'default_value\'', expected: [ 'var_with_default_value', ], }, { title: '[IF + CHAINED] if statement [expect silenced], inner blocks [only need first cmd silence]', input: ` # checks all edge cases for if statements if set -q var1 && set -q var2 set -q var3 && set var1 'new_value' && set var2 'new_value' end `, expected: [ ], }, { title: '[IF] normal if statement to silence a variable', input: 'if set -q var1; echo \'var1 is set\'; end', expected: [ ], }, ]; // fix this usecase for when first `set` with child `nextSibling.type ==== conditional_execution` // does not have any value after `set` // ``` // set uvar // and set uvar 'new_value' // ``` // This should have a diagnostic but does not currently, // checkout files: // - ../src/diagnostics/node-types.ts // - ../src/diagnostics/validate.ts // FIX SPECIFIC function `isConditionalStatement()` // testcases.forEach(({ title, input, expected, shouldRun }) => { // if (shouldRun) { // it.only(title, () => { // // console.log(title); // // console.log('-'.repeat(70)); // const { rootNode } = parser.parse(input); // // console.log(rootNode.text); // // console.log('-'.repeat(70)); // const result: SyntaxNode[] = []; // for (const child of getChildNodes(rootNode)) { // if (isConditionalWithoutQuietCommand(child)) { // // console.log('conditional', {text: child.text}); // result.push(child); // } // } // expect(result.map(r => r.text)).toEqual(expected); // }); // } // }); }); describe.skip('fish --no-execute diagnostics', () => { afterEach(async () => { workspaceManager.clear(); }); it('NODE_TEST: fish --no-execute diagnostic 1', async () => { const input = ` function foo echo "hi"`; await withTempFishFile(input, async ({ document, path }) => { console.log({ document, path }); analyzer.ensureCachedDocument(document); const result = getNoExecuteDiagnostics(document); console.log({ result }); expect(result.length).toBe(1); }); }); it('VALIDATE: fish --no-execute diagnostic 2', async () => { const input = ` function foo echo "hi"`; await withTempFishFile(input, async ({ document, path }) => { console.log({ document, path }); analyzer.ensureCachedDocument(document); const result = getNoExecuteDiagnostics(document); const finalRes = getNoExecuteDiagnostics(document); console.log({ finalRes, result }); }); }); }); describe('diagnostic workspace', () => { const tw = TestWorkspace .create({ name: 'diagnostic-workspace' }) .addFiles( { relativePath: 'script-1.fish', content: [ 'function foo', ' echo "hello world"', 'end', ], }, { relativePath: 'script-2.fish', content: [ 'set var1 value1', 'set var2 value2', 'function script-2', ' echo script-2', 'end', ], }, { relativePath: 'script-3.fish', content: [ 'source ./script-1.fish', 'source ./script-2.fish', 'foo', 'script-2', ], }, { relativePath: 'script-4.fish', content: [ 'source ./script-3.fish', 'foo', 'script-2', '', 'function script-4', ' echo \'inside script-4\'', 'end', '', ], }, { relativePath: 'script-5.fish', content: [ 'function wrapper-func', ' function inner-func', ' echo "inside inner-func"', ' end', 'end', '# @fish-lsp-disable 4004', 'function disabled-wrapper', ' function disabled-inner', ' echo "inside disabled-inner"', ' end', 'end', '# @fish-lsp-enable 4004', 'function another-wrapper', ' function another-inner', ' echo "inside another-inner"', ' end', 'end', ], }, { relativePath: 'conf.d/autoloaded-foo.fish', content: [ 'set -Ux universal_var "I am universal"', 'source ./script-1.fish', 'source ./script-2.fish', 'function __foo-wrapper', ' foo $argv', 'end', 'function __script-2-wrapper', ' function __wrapper', ' script-2 $argv', ' end', 'end', '# @fish-lsp-disable 4004', 'function baz-wrapper', ' function baz', ' echo "inside baz"', ' end', 'end', '# @fish-lsp-enable 4004', 'function bar-wrapper', ' function bar', ' echo "inside bar"', ' end', 'end', 'unknown_command_here', ], }, { relativePath: 'conf.d/lots-of-comments.fish', content: Array.from({ length: 2500 }, (_, i) => `# This is comment line number ${i + 1}`).join('\n'), }, ).initialize(); let script1: LspDocument; let script2: LspDocument; let script3: LspDocument; let script4: LspDocument; let script5: LspDocument; let autoloadedFoo: LspDocument; let lotsOfComments: LspDocument; beforeAll(async () => { await Analyzer.initialize(); script1 = tw.find('script-1.fish')!; script2 = tw.find('script-2.fish')!; script3 = tw.find('script-3.fish')!; script4 = tw.find('script-4.fish')!; script5 = tw.find('script-5.fish')!; autoloadedFoo = tw.find('conf.d/autoloaded-foo.fish')!; lotsOfComments = tw.find('conf.d/lots-of-comments.fish')!; }); it('VALIDATE: setup workspace files', () => { expect(script1).toBeDefined(); expect(script2).toBeDefined(); expect(script3).toBeDefined(); expect(script4).toBeDefined(); expect(autoloadedFoo).toBeDefined(); }); it.skip('VALIDATE: diagnostics across workspace files', async () => { const { root, document: doc, sourceNodes, flatSymbols } = analyzer.analyze(script4).ensureParsed(); sourceNodes.forEach((sourceNode) => { console.log({ text: sourceNode.text, }); }); Array.from(analyzer.collectAllSources(doc.uri)).forEach((s, i) => console.log(i, s)); const sourcedSymbols: FishSymbol[] = []; analyzer.collectAllSources(doc.uri).forEach((s) => { const cached = analyzer.analyzeUri(s); cached?.flatSymbols .filter(s => s.isRootLevel() || s.isGlobal()) .filter(s => s.name !== 'argv') .forEach(sym => { sourcedSymbols.push(sym); }); }); // NOW USE: analyzer.allReachableSymbols(doc.document.uri) for (const symbol of sourcedSymbols) { console.log({ type: 'sourced', name: symbol.name, uri: symbol.uri, }); } for (const symbol of flatSymbols) { console.log({ type: 'current', name: symbol.name, uri: symbol.uri, }); } analyzer.allReachableSymbols(doc.uri).forEach((s, i) => { console.log(i, { tpe: 'all-reachable', name: s.name, uri: s.uri, kind: s.kind, }); }); }); it('VALIDATE: definitions across workspace files', async () => { const { root, document: doc } = analyzer.analyze(script4).ensureParsed(); const result = await getDiagnosticsAsync(root, doc); // result.forEach(d => logDiagnostics(d, root)); expect(result.map(mapDiagnostics)).toEqual([ { code: 4004, text: 'script-4' }, ]); }); it('VALIDATE: @fish-lsp-(disable|enable)', async () => { const { root, document: doc } = analyzer.analyze(autoloadedFoo).ensureParsed(); const result = await getDiagnosticsAsync(root, doc); // result.forEach(d => logDiagnostics(d, root)); expect(result.map(mapDiagnostics)).toEqual([ { code: 4004, text: '__wrapper' }, { code: 4004, text: 'bar' }, { code: 7001, text: 'unknown_command_here' }, ]); }); it('VALIDATE: universal variable definition in autoloaded file', async () => { const { root, document: doc } = analyzer.analyze(script5).ensureParsed(); const result = await getDiagnosticsAsync(root, doc); result.forEach(d => logDiagnostics(d, root)); // expect(result.map(mapDiagnostics)).toContainEqual({ // code: 5001, // text: '-Ux', // }); }); it('VALIDATE: large number of comments', async () => { const { root, document: doc } = analyzer.analyze(lotsOfComments).ensureParsed(); const result = await getDiagnosticsAsync(root, doc); result.forEach(d => logDiagnostics(d, root)); expect(result.length).toBe(0); }); }); describe('EXTRA: unused variables tests', () => { const tw = TestWorkspace .create({ name: 'diagnostic-for-loop-workspace' }) .addFiles( { relativePath: 'conf.d/for-loop-test.fish', content: [ 'for i in (seq 1 10);', ' # `i` is not used;', 'end', ].join('\n'), }, ).initialize(); let forFile: LspDocument; beforeAll(async () => { await Analyzer.initialize(); forFile = tw.find('conf.d/for-loop-test.fish')!; }); it('VALIDATE: for i in (seq 1 10); echo $i; end', async () => { const doc = forFile; const { root } = analyzer.analyze(doc).ensureParsed(); const diagnostics = await getDiagnosticsAsync(root, doc); diagnostics.forEach(d => logDiagnostics(d, root)); expect(diagnostics).toHaveLength(0); }); }); }); // expect(definitions.map(d => d.text)).toEqual([ // 'foo', // 'variable_1', // 'variable_2' // ]); /** * TODO: * write argparse handler */ // it('NODE_TEST: argparse', async () => { // // // ================================================ FILE: tests/document-highlights.test.ts ================================================ import { createFakeLspDocument, setLogger } from './helpers'; import { analyzer, Analyzer } from '../src/analyze'; import { initializeParser } from '../src/parser'; import { getDocumentHighlights } from '../src/document-highlight'; import * as Parser from 'web-tree-sitter'; import { DocumentHighlight, DocumentHighlightKind, Position } from 'vscode-languageserver'; import { isCommandName, isFunctionDefinitionName, isVariableDefinitionName } from '../src/utils/node-types'; import { getRange } from '../src/utils/tree-sitter'; import { LspDocument } from '../src/document'; /** * * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_documentHighlight * * The document highlight request is sent from the client to the server to resolve * document highlights for a given text document position. For programming languages, * this usually highlights all references to the symbol scoped to this file. However, * we kept ‘textDocument/documentHighlight’ and ‘textDocument/references’ separate * requests since the first one is allowed to be more fuzzy. Symbol matches usually * have a DocumentHighlightKind of Read or Write whereas fuzzy or textual matches use * Text as the kind. * * * * So, there is 3 kinds of documentHighlights: * 1. Text (fuzzy or textual matches) * 2. Read (like reading from a variable) * 3. Write (write access to a symbol, like writing to a variable) */ function createHighlightRequest(doc: LspDocument, position: Position) { return { textDocument: { uri: doc.uri }, position, }; } let parser: Parser; let getHighlights: (params: { textDocument: { uri: string; }; position: { line: number; character: number; }; }) => DocumentHighlight[]; describe('document-highlights test', () => { setLogger(); beforeAll(async () => { parser = await initializeParser(); await Analyzer.initialize(); getHighlights = getDocumentHighlights(analyzer); }); describe.skip('3 basic types of documentHighlights', () => { /** * A textual occurrence. */ it('test text', () => { }); // it('test read', () => { // // }); it('test write', () => { }); }); describe('test `text` documentHighlights', () => { describe('variable', () => { it('definition, and reference', () => { const sourceCode = 'set var_1 10; set var_2 20; set var_3 30; echo $var_1'; const doc = createFakeLspDocument('functions/test.fish', sourceCode); analyzer.analyze(doc); const searchDefNode = analyzer.getNodes(doc.uri).find((node) => node.text === 'var_1' && isVariableDefinitionName(node))!; // set var_1 10 const searchRefNode = analyzer.getNodes(doc.uri).find((node) => node.text === 'var_1' && node.type === 'variable_name')!; // echo $var_1 const requests = [ searchDefNode, searchRefNode, ].map((node) => createHighlightRequest(doc, getRange(node).start)); const results: DocumentHighlight[][] = []; requests.forEach((req) => { const highlights = getHighlights(req); // expect(highlights).toHaveLength(2); expect(highlights[0]?.kind).toBe(1); // DocumentHighlightKind.Text results.push(highlights); }); expect(results[0]).toEqual(results[1]); }); it('universal variable w/o definition', () => { const sourceCode = ` if set -q PATH echo "PATH is set" end`; const doc = createFakeLspDocument('config.fish', sourceCode); analyzer.analyze(doc); const searchNode = analyzer.getNodes(doc.uri).find((node) => node.text === 'PATH')!; // set var_1 10 const requests = [ searchNode, ].map((node) => createHighlightRequest(doc, getRange(node).start)); const results: DocumentHighlight[][] = []; requests.forEach((req) => { const highlights = getHighlights(req); expect(highlights).toHaveLength(0); if (highlights.length === 0) return; expect(highlights[0]?.kind).toBe(DocumentHighlightKind.Text); // DocumentHighlightKind.Text results.push(highlights); }); expect(results).toHaveLength(0); }); }); describe('function', () => { it('definition, and reference', () => { const sourceCode = ` function my_func echo "hello" end my_func`; const doc = createFakeLspDocument('functions/test.fish', sourceCode); analyzer.analyze(doc); const searchDefNode = analyzer.getNodes(doc.uri).find((node) => node.text === 'my_func' && isFunctionDefinitionName(node))!; // function my_func const searchRefNode = analyzer.getNodes(doc.uri).find((node) => node.text === 'my_func' && isCommandName(node))!; // my_func const requests = [ searchDefNode, searchRefNode, ].map((node) => createHighlightRequest(doc, getRange(node).start)); const results: DocumentHighlight[][] = []; requests.forEach((req, idx) => { const highlights = getHighlights(req); // expect(highlights).toHaveLength(idx+1); results.push(highlights); }); expect(results).toHaveLength(2); expect(results[0]).toEqual(results[1]); }); it('edge case (BUG: #66)', () => { const sourceCode = `function foo true true true if true true end end`; const doc = createFakeLspDocument('functions/foo.fish', sourceCode); analyzer.analyze(doc); const testPosition = { character: 1, line: 1 }; const request = { textDocument: { uri: doc.uri }, position: testPosition, }; const highlights = getHighlights(request); expect(highlights).toHaveLength(0); }); }); }); // describe('test `read` documentHighlights', () => { // // }); // // describe('test `write` documentHighlights', () => { // // }); // // // https://github.com/ndonfris/fish-lsp/issues/66 // describe('Empty test input test cases (BUG: #66)', () => { // // }); }); ================================================ FILE: tests/document-test-helpers.ts ================================================ /** * Test helpers for working with the TextDocuments singleton in tests. * * The new TextDocuments implementation from vscode-languageserver doesn't expose * methods like `open()` or `clear()` - it's designed to work through connection events. * * These helpers simulate the document lifecycle events that would normally come from * a connected LSP client, allowing tests to manipulate the documents singleton. */ import { documents, LspDocument } from '../src/document'; import { DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams } from 'vscode-languageserver-protocol'; /** * Simulates opening a document in the LSP client. * This triggers the same flow as when a real client sends textDocument/didOpen. * * @param doc The document to open */ export function testOpenDocument(doc: LspDocument): void { const params: DidOpenTextDocumentParams = { textDocument: { uri: doc.uri, languageId: doc.languageId, version: doc.version, text: doc.getText(), }, }; // Access the private _syncedDocuments map to add the document // This simulates what happens when the onDidOpenTextDocument event fires const syncedDocs = (documents as any)._syncedDocuments as Map; syncedDocs.set(doc.uri, doc); // Trigger the onDidOpen event const onDidOpenEmitter = (documents as any)._onDidOpen; if (onDidOpenEmitter) { onDidOpenEmitter.fire({ document: doc }); } // Trigger the onDidChangeContent event (happens on open too) const onDidChangeContentEmitter = (documents as any)._onDidChangeContent; if (onDidChangeContentEmitter) { onDidChangeContentEmitter.fire({ document: doc }); } } /** * Simulates closing a document in the LSP client. * This triggers the same flow as when a real client sends textDocument/didClose. * * @param uri The URI of the document to close */ export function testCloseDocument(uri: string): void { const doc = documents.get(uri); if (!doc) return; // Remove from synced documents const syncedDocs = (documents as any)._syncedDocuments as Map; syncedDocs.delete(uri); // Trigger the onDidClose event const onDidCloseEmitter = (documents as any)._onDidClose; if (onDidCloseEmitter) { onDidCloseEmitter.fire({ document: doc }); } } /** * Clears all documents from the TextDocuments singleton. * This is useful for test cleanup and resetting state between tests. */ export function testClearDocuments(): void { const syncedDocs = (documents as any)._syncedDocuments as Map; // Get all URIs before clearing const allUris = Array.from(syncedDocs.keys()); // Trigger close events for all documents for (const uri of allUris) { testCloseDocument(uri); } // Clear the map syncedDocs.clear(); } /** * Simulates a document change event. * This is useful for testing edit scenarios. * * @param uri The URI of the document to change * @param newText The new text content * @param version Optional new version number */ export function testChangeDocument(uri: string, newText: string, version?: number): void { const doc = documents.get(uri); if (!doc) { throw new Error(`Document not found: ${uri}`); } // Update the document const newVersion = version ?? doc.version + 1; doc.update([{ text: newText }], newVersion); // Trigger the onDidChangeContent event const onDidChangeContentEmitter = (documents as any)._onDidChangeContent; if (onDidChangeContentEmitter) { onDidChangeContentEmitter.fire({ document: doc }); } } /** * Gets the count of currently managed documents. * Useful for test assertions. * * @returns The number of documents currently managed */ export function testGetDocumentCount(): number { const syncedDocs = (documents as any)._syncedDocuments as Map; return syncedDocs.size; } /** * Checks if a document is currently managed by the TextDocuments singleton. * * @param uri The URI to check * @returns true if the document is managed */ export function testHasDocument(uri: string): boolean { return documents.get(uri) !== undefined; } ================================================ FILE: tests/document.test.ts ================================================ import { documents, LspDocument } from '../src/document'; import { resolveLspDocumentForHelperTestFile } from './helpers'; import { initializeParser } from '../src/parser'; import { SyntaxNode } from 'web-tree-sitter'; import TestWorkspace, { TestFile } from './test-workspace-utils'; import { Workspace } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { logger } from '../src/logger'; describe('LspDocument tests', () => { beforeAll(() => { logger.setSilent(); }); describe('resolveLspDocumentForHelperTestFile() tests', () => { it('test an document is created not in ~/.config/fish/functions/ directory', () => { const doc: LspDocument = resolveLspDocumentForHelperTestFile('./fish_files/simple/set_var.fish', false); expect(doc).not.toBeNull(); expect(doc.isAutoloaded()).toBeFalsy(); }); it('test an document is created in ~/.config/fish/functions/ directory', () => { const doc: LspDocument = resolveLspDocumentForHelperTestFile('./fish_files/simple/set_var.fish'); expect(doc).not.toBeNull(); expect(doc.isAutoloaded()).toBeTruthy(); expect(doc.uri.endsWith('functions/set_var.fish')).toBeTruthy(); }); it('testing ability to parse a document', async () => { const parser = await initializeParser(); const doc: LspDocument = resolveLspDocumentForHelperTestFile('./fish_files/simple/set_var.fish'); const root: SyntaxNode = parser.parse(doc.getText()).rootNode; expect(root.children).toHaveLength(2); expect(doc.lineCount === 2).toBeTruthy(); }); }); describe('LspDocument methods/properties', () => { const ws = TestWorkspace.create({ name: 'lsp-document-test', debug: false, }).addFiles( TestFile.config(` set -gx EDITOR nvim set -gx VISUAL nvim set -gx PATH /usr/local/bin $PATH function greet echo "Hello, World!" end function keybindings bind \\e[1~ beginning-of-line bind \\e[4~ end-of-line end`, ), TestFile.confd('say_hello.fish', ` function say_hello echo "Hello from say_hello function!" end`, ), TestFile.script('run_script.fish', `#!/usr/bin/env fish function main_1 set -f fish_trace on echo 'Running main_1' end function main_2 set fish_trace on echo 'Running main_2' end function main_3 set -x fish_trace on echo 'Running main_3' end`, ), TestFile.function('complex_function.fish', ` function complex_function argparse a/alpha b/beta c/charlie d/delta h/help -- $argv or return 1 function print_help # should have diagnostic 4004 echo "Usage: complex_function [-a|--alpha] [-b|--beta] [-c|--charlie] [-d|--delta] [-h|--help]" end set -ql _flag_help && print_help && return 0 set input '' set -ql _flag_alpha && set -a input "Alpha" set -ql _flag_beta && set -a input "Beta" set -ql _flag_charlie && set -a input "Charlie" set -ql _flag_delta && set -a input "Delta" echo $input end function helper_function # should have diagnostic 4004 echo "This is a helper function." end`, ), TestFile.completion('complex_function.fish', ` complete -c complex_function -s a -l alpha -d "Alpha option" complete -c complex_function -s b -l beta -d "Beta option" complete -c complex_function -s c -l charlie -d "Charlie option" complete -c complex_function -s d -l delta -d "Delta option" complete -c complex_function -s h -l help -d "show help message"`), ).initialize(); describe('base', () => { let config_doc: LspDocument; let script_doc: LspDocument; let confd_doc: LspDocument; let func_doc: LspDocument; let cmp_doc: LspDocument; beforeAll(async () => { config_doc = ws.find('config.fish')!; script_doc = ws.find('run_script.fish')!; confd_doc = ws.find('conf.d/say_hello.fish')!; func_doc = ws.find('functions/complex_function.fish')!; cmp_doc = ws.find('completions/complex_function.fish')!; }); it('total documents', () => { expect(ws.documents.length).toBe(5); }); it('get', () => { ws.documents.forEach(doc => { const fetched = ws.find(doc.uri)!; expect(fetched.uri).toBe(doc.uri); }); }); it('lineCount', () => { expect(config_doc.lineCount).toBe(14); expect(script_doc.lineCount).toBe(16); expect(confd_doc.lineCount).toBe(4); expect(func_doc.lineCount).toBe(24); expect(cmp_doc.lineCount).toBe(6); }); it('getText()', () => { const text = func_doc.getText(); expect(text).toContain('function complex_function'); expect(text).toContain('argparse a/alpha b/beta c/charlie d/delta h/help -- $argv'); expect(text).toContain('function print_help'); expect(text).toContain('function helper_function'); }); it('isAutoloaded()', () => { const autoloaded_docs = [config_doc, confd_doc, func_doc, cmp_doc]; const non_autoloaded_docs = [script_doc]; autoloaded_docs.forEach(doc => { expect(doc.isAutoloadedUri()).toBeTruthy(); if (doc.getAutoloadType() === 'completions') { expect(doc.isAutoloaded()).toBeFalsy(); } }); non_autoloaded_docs.forEach(doc => { expect(doc.isAutoloaded()).toBeFalsy(); }); }); it('getAutoloadType()', () => { expect(config_doc.getAutoloadType()).toBe('config'); expect(confd_doc.getAutoloadType()).toBe('conf.d'); expect(func_doc.getAutoloadType()).toBe('functions'); expect(cmp_doc.getAutoloadType()).toBe('completions'); expect(script_doc.getAutoloadType()).toBe(''); }); it('getFileName()', () => { expect(config_doc.getFileName()).toBe('config.fish'); expect(confd_doc.getFileName()).toBe('say_hello.fish'); expect(func_doc.getFileName()).toBe('complex_function.fish'); expect(cmp_doc.getFileName()).toBe('complex_function.fish'); expect(script_doc.getFileName()).toBe('run_script.fish'); }); it('hasShebang()', () => { expect(script_doc.hasShebang()).toBeTruthy(); [config_doc, confd_doc, func_doc, cmp_doc].forEach(doc => { expect(doc.hasShebang()).toBeFalsy(); }); }); it('getLine()', () => { expect(config_doc.getLine(1)).toBe('set -gx EDITOR nvim'); expect(script_doc.getLine(0)).toBe('#!/usr/bin/env fish'); expect(confd_doc.getLine(1)).toBe('function say_hello'); expect(func_doc.getLine(5)).toBe(' function print_help # should have diagnostic 4004'); expect(cmp_doc.getLine(4)).toBe('complete -c complex_function -s d -l delta -d "Delta option" '); }); it('version()', () => { ws.documents.forEach(doc => { expect(doc.version).toBe(1); }); }); it('positionAt()', () => { expect(func_doc.positionAt(0)).toEqual({ line: 0, character: 0 }); expect(func_doc.positionAt(10)).toEqual({ line: 1, character: 9 }); expect(func_doc.positionAt(25)).toEqual({ line: 1, character: 24 }); expect(func_doc.positionAt(100)).toEqual({ line: 3, character: 11 }); }); it('offsetAt()', () => { expect(func_doc.offsetAt({ line: 0, character: 0 })).toBe(0); expect(func_doc.offsetAt({ line: 1, character: 9 })).toBe(10); expect(func_doc.offsetAt({ line: 1, character: 24 })).toBe(25); expect(func_doc.offsetAt({ line: 3, character: 11 })).toBe(100); }); it('getRelativeFilenameToWorkspace()', () => { expect(config_doc.getRelativeFilenameToWorkspace()).toBe('config.fish'); expect(confd_doc.getRelativeFilenameToWorkspace()).toBe('conf.d/say_hello.fish'); expect(func_doc.getRelativeFilenameToWorkspace()).toBe('functions/complex_function.fish'); expect(cmp_doc.getRelativeFilenameToWorkspace()).toBe('completions/complex_function.fish'); expect(script_doc.getRelativeFilenameToWorkspace()).toBe('run_script.fish'); }); it('getTree()', async () => { const tree = func_doc.getTree(); expect(tree.length).toBeGreaterThan(10); }); it('updateVersion()', () => { const initialVersion = func_doc.version; func_doc.updateVersion(2); expect(func_doc.version).toBe(initialVersion + 1); }); describe('static', () => { it('is()', () => { expect(LspDocument.is(config_doc)).toBeTruthy(); expect(LspDocument.is(confd_doc)).toBeTruthy(); expect(LspDocument.is(func_doc)).toBeTruthy(); expect(LspDocument.is(cmp_doc)).toBeTruthy(); expect(LspDocument.is(script_doc)).toBeTruthy(); }); }); }); describe('documents', () => { it('querying all function definitions', () => { expect(documents.all()).toHaveLength(5); }); it('querying all isAutoloadedUri documents', () => { const autoloadedDocs = documents.all().filter(doc => doc.isAutoloadedUri()); expect(autoloadedDocs).toHaveLength(4); }); it('find completions/functions documents', () => { const funcDocs = documents.all().filter(doc => doc.getAutoloadType() === 'functions'); const cmpDocs = documents.all().filter(doc => doc.getAutoloadType() === 'completions'); expect(funcDocs).toHaveLength(1); expect(cmpDocs).toHaveLength(1); expect(funcDocs[0]!.getAutoLoadName()).toBe(cmpDocs[0]!.getAutoLoadName()); }); }); describe('workspace/workspaceManager', () => { let workspace: Workspace; beforeEach(async () => { workspace = ws.workspace!; workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); }); it('all workspace uris', async () => { const uris = workspace.uris.all; expect(uris).toHaveLength(5); }); it('find all functions in workspace', async () => { const results = workspace.allDocuments().filter(d => d.isAutoloadedFunction()); expect(results).toHaveLength(1); }); it('find all possible fish files with autoloaded functions', async () => { const results = workspace.allDocuments().filter(d => d.isAutoloadedUri()); expect(results).toHaveLength(4); [ 'config.fish', 'conf.d/say_hello.fish', 'functions/complex_function.fish', 'completions/complex_function.fish', ].forEach(expectedPath => { expect(results.map(r => r.getRelativeFilenameToWorkspace())).toContain(expectedPath); }); }); it('get document by ending path', async () => { const found = workspace.findDocument(d => d.uri.endsWith('functions/complex_function.fish')); expect(found).not.toBeNull(); }); it('workspace re-analyze all documents', async () => { workspaceManager.all.forEach(ws => ws.setAllPending()); const result = await workspaceManager.analyzePendingDocuments(); expect(result.totalDocuments).toBe(5); }); }); }); }); ================================================ FILE: tests/embedded-functions-resolution.test.ts ================================================ import { Analyzer, analyzer } from '../src/analyze'; import { LspDocument } from '../src/document'; import { nodesGen, pointToPosition } from '../src/utils/tree-sitter'; import { createMockConnection, setupStartupMock } from './helpers'; import TestWorkspace from './test-workspace-utils'; // Setup startup mocks before importing FishServer setupStartupMock(); // Now import FishServer after the mock is set up import FishServer from '../src/server'; import { initializeParser } from '../src/parser'; import { AutoloadedPathVariables, setupProcessEnvExecFile } from '../src/utils/process-env'; import { env } from '../src/utils/env-manager'; // import { SyncFileHelper } from '../src/utils/file-operations'; import path from 'path'; import fs from 'fs'; describe('embedded:functions/*.fish lookup', () => { let server: FishServer; beforeAll(async () => { await setupProcessEnvExecFile(); await initializeParser(); await Analyzer.initialize(); // Create mock connection const mockConnection = createMockConnection(); const mockInitializeParams = { processId: 1234, rootUri: 'file:///test/workspace', rootPath: '/test/workspace', capabilities: { workspace: { workspaceFolders: true, }, textDocument: { completion: { completionItem: { snippetSupport: true, }, }, }, }, workspaceFolders: [], }; const result = await FishServer.create(mockConnection, mockInitializeParams as any); server = result.server; server.backgroundAnalysisComplete = true; // Enable completions }); const TEST_WORKSPACE_1 = TestWorkspace.create({ name: 'embedded-functions-resolution' }) .addFiles( { relativePath: 'functions/my_test.fish', content: [ 'function my_test', ' fish_add_path $__fish_data_dir', ' echo "Embedded function executed"', 'end', ], }, { relativePath: 'functions/other_test.fish', content: [ 'function other_test', ' fish_add_path $__fish_data_dir', ' echo "other test function executed"', 'end', ], }, { relativePath: 'test_script.fish', content: [ '#!/usr/bin/env fish', 'source functions/my_test.fish', 'source functions/other_test.fish', 'my_test', 'other_test', 'funced my_test', 'alias f=my_test', ], }, ).initialize(); let myTestDoc: LspDocument; let otherTestDoc: LspDocument; let testScriptDoc: LspDocument; beforeAll(async () => { await setupProcessEnvExecFile(); await initializeParser(); await Analyzer.initialize(); myTestDoc = TEST_WORKSPACE_1.find('functions/my_test.fish')!; otherTestDoc = TEST_WORKSPACE_1.find('functions/other_test.fish')!; testScriptDoc = TEST_WORKSPACE_1.find('test_script.fish')!; }); it('verify documents loaded', () => { expect(myTestDoc).toBeDefined(); expect(otherTestDoc).toBeDefined(); expect(testScriptDoc).toBeDefined(); }); it('should resolve embedded functions correctly', () => { const { document: doc, root } = analyzer.analyze(myTestDoc).ensureParsed(); const cmdNode = nodesGen(root).find(n => n.text === 'fish_add_path')!; console.log(cmdNode.text); const location = analyzer.getDefinitionLocation(doc, pointToPosition(cmdNode.startPosition)); console.log({ location, }); const potentialPaths: string[] = []; for (const autoloadedVar of env.getAutoloadedKeys()) { if (env.getAsArray(autoloadedVar)?.length === 0) { continue; } if (autoloadedVar === 'fish_complete_path') { continue; } if (autoloadedVar === 'fish_function_path') { env.getAsArray(autoloadedVar).forEach(p => { potentialPaths.push(path.join(p, 'fish_add_path.fish')); }); continue; // } else if (autoloadedVar === 'fish_user_paths') { // env.getAsArray(autoloadedVar).forEach(p => { // potentialPaths.push(path.join(p, 'functions', 'fish_add_path.fish')); // }); // continue; } if (env.getAsArray(autoloadedVar).length === 1) { const value = env.getFirstValueInArray(autoloadedVar); potentialPaths.push(path.join(`${value}`, 'functions', 'fish_add_path.fish')); } } console.log({ potentialPaths, }); // env.getAutoloadedKeys().forEach(k => { // // console.log({ // // k, // // v: SyncFileHelper.expandEnvVars(`$${k}`), // // }) // if (SyncFileHelper.exists(SyncFileHelper.expandEnvVars(path.join(`$${k}`, 'functions', 'fish_add_path.fish')))) { // console.log(`Found fish_add_path.fish in $${k}`); // } // // console.log(SyncFileHelper.exists(SyncFileHelper.expandEnvVars(path.join(`$${k}`, 'functions', 'fish_add_path.fish')))) // // console.log(SyncFileHelper.expandEnvVars(path.join(`$${k}`, 'functions', 'fish_add_path.fish'))); // }); console.log(AutoloadedPathVariables.findAutoloadedFunctionPath('fish_add_path')); }); it.only('should resolve my_test function definition', () => { const { document: doc, commandNodes, root } = analyzer.analyze(myTestDoc).ensureParsed(); const cmdNode = nodesGen(root).find(n => n.text === 'fish_add_path')!; console.log({ doc: { uri: doc.uri, path: doc.path, }, cmdNode: { text: cmdNode.text, startPosition: cmdNode.startPosition, }, }); // const location = analyzer.getDefinitionLocation(doc, pointToPosition(cmdNode.startPosition)); const files: string[] = []; env.getAsArray('__fish_data_dir').forEach(p => { console.log('data dir entry:', p); files.push(path.join(p, 'functions', 'fish_add_path.fish')); }); env.getAsArray('__fish_sysconfdir').forEach(p => { console.log('sysconfdir entry:', p); files.push(path.join(p, 'functions', 'fish_add_path.fish')); }); env.getAsArray('__fish_sysconf_dir').forEach(p => { console.log('sysconf_dir entry:', p); files.push(path.join(p, 'functions', 'fish_add_path.fish')); }); env.getAsArray('__fish_vendor_functionsdirs').forEach(p => { console.log('vendor functions dir entry:', p); files.push(path.join(p, 'fish_add_path.fish')); }); env.getAsArray('fish_function_path').forEach(p => { console.log('fish_function_path entry:', p); files.push(path.join(p, 'fish_add_path.fish')); }); env.getAsArray('__fish_config_dir').forEach(p => { console.log('config dir entry:', p); files.push(path.join(p, 'functions', 'fish_add_path.fish')); }); let i = 0; for (const f of files) { console.log({ f, i }); i++; if (fs.existsSync(f)) { console.log('Found file at path:', f); break; } } // files.forEach(f => { // console.log('checking file:', f); // }) }); it.only('should resolve fish_add_path function definition path', () => { const { document: doc, root } = analyzer.analyze(myTestDoc).ensureParsed(); const cmdNode = nodesGen(root).find(n => n.text === 'fish_add_path')!; console.log({ doc: { uri: doc.uri, path: doc.path, }, text: cmdNode.text, }); // const location = analyzer.getDefinitionLocation(doc, pointToPosition(cmdNode.startPosition)); console.log(env.findAutoloadedFunctionPath(cmdNode.text!).at(0)); analyzer.getDefinitionLocation(myTestDoc, pointToPosition(cmdNode.startPosition)); }); }); ================================================ FILE: tests/example-test-workspace-usage.test.ts ================================================ import { workspaceManager } from '../src/utils/workspace-manager'; import { setLogger } from './helpers'; import { TestWorkspace, TestFile, Query, DefaultTestWorkspaces, focusedWorkspace } from './test-workspace-utils'; describe('Example Test Workspace Usage', () => { describe('Basic Usage Example', () => { const testWorkspace = TestWorkspace.create({ name: 'example_basic', autoFocusWorkspace: true }) .addFiles( TestFile.function('greet', ` function greet echo "Hello, $argv[1]!" end`), TestFile.completion('greet', ` complete -c greet -a "(ls)" complete -c greet -l help -d "Show help"`), TestFile.config(` set -g fish_greeting "Welcome to test!" set -gx PATH $PATH /usr/local/test/bin`), TestFile.confd('setup', ` function setup_test --on-event fish_prompt if not set -q test_loaded set -g test_loaded true echo "Test environment loaded" end end`), ).initialize(); it('should create all expected documents', () => { console.log({ focusedWorkspace: focusedWorkspace?.name, focusedWorkspaceDocs: focusedWorkspace?.allDocuments().length, }); expect(focusedWorkspace?.allDocuments().length).toBe(4); }); it('should find documents by simple path', () => { const greetFunc = testWorkspace.getDocument('functions/greet.fish'); expect(greetFunc).toBeDefined(); expect(greetFunc?.getText()).toContain('function greet'); }); it('should support advanced querying', () => { // Get all function files const functions = focusedWorkspace!.allDocuments().filter(d => d.getAutoloadType() === 'functions'); expect(functions.length).toBeGreaterThanOrEqual(1); expect(functions[0]!.getText()).toContain('function greet'); // Get files by name across types const greetFiles = testWorkspace.getDocuments(Query.withName('greet')); expect(greetFiles).toHaveLength(2); // function and completion // Get first autoloaded file const firstAutoloaded = testWorkspace.getDocuments(Query.firstMatch().autoloaded()); expect(firstAutoloaded).toHaveLength(1); // Complex query: functions and completions with specific name const specificFiles = testWorkspace.getDocuments( Query.functions().withName('greet'), Query.completions().withName('greet'), ); expect(specificFiles).toHaveLength(2); }); it('should provide workspace analysis', () => { const workspace = testWorkspace.getWorkspace(); expect(workspace).toBeDefined(); expect(workspace?.allDocuments().length).toBeGreaterThan(0); }); it('should support live file editing', () => { const originalDoc = testWorkspace.getDocument('functions/greet.fish'); const originalContent = originalDoc?.getText(); testWorkspace.editFile('functions/greet.fish', ` function greet echo "Hello there, $argv[1]!" echo "Nice to meet you!" end`); const updatedDoc = testWorkspace.getDocument('functions/greet.fish'); expect(updatedDoc?.getText()).toContain('Hello there'); expect(updatedDoc?.getText()).not.toBe(originalContent); }); }); describe('Using Predefined Workspaces', () => { const basicWorkspace = DefaultTestWorkspaces.basicFunctions(); basicWorkspace.setup(); it('should work with predefined basic functions workspace', () => { expect(basicWorkspace.documents.length).toBeGreaterThan(2); const greetFunc = basicWorkspace.getDocument('greet.fish'); expect(greetFunc).toBeDefined(); const addFunc = basicWorkspace.getDocument('add.fish'); expect(addFunc).toBeDefined(); }); }); describe('Advanced Features', () => { // should log const advancedWorkspace = TestWorkspace.create({ name: 'example_advanced', // debug: true, }).addFiles( TestFile.script('deploy', ` #!/usr/bin/env fish echo "Deploying application..." # Deploy logic here`).withShebang(), TestFile.function('helper', ` function helper echo "Helper function" end`), ).initialize(); // advancedWorkspace.setup(); // it('should handle scripts with shebangs', () => { const deployScript = advancedWorkspace.getDocument('deploy.fish'); expect(deployScript?.getText()).toContain('#!/usr/bin/env fish'); }); it('should support workspace inspection', () => { const fileTree: string = focusedWorkspace!.allDocuments().map(doc => [doc.getRelativeFilenameToWorkspace(), doc.getTree()].join('\n')).join('\n'); expect(fileTree).toContain('deploy.fish'); expect(fileTree).toContain('functions'); }); it('should create snapshots', () => { const snapshotPath = advancedWorkspace.writeSnapshot(); expect(snapshotPath).toContain('.snapshot'); // Test loading from snapshot const restoredWorkspace = TestWorkspace.fromSnapshot(snapshotPath); expect(restoredWorkspace.name).toBe('example_advanced'); }); }); describe('Complex Project Simulation', () => { const projectWorkspace = DefaultTestWorkspaces.projectWorkspace(); projectWorkspace.setup(); it('should simulate a complete project structure', () => { expect(projectWorkspace.documents.length).toBeGreaterThan(5); // Check for build function const buildFunc = projectWorkspace.getDocument('build.fish'); expect(buildFunc?.getText()).toContain('Building project'); // Check for install script const installScript = projectWorkspace.getDocument('install.fish'); expect(installScript?.getText()).toContain('#!/usr/bin/env fish'); // Use queries to get different file types const functions = projectWorkspace.getDocuments(Query.functions()); const completions = projectWorkspace.getDocuments(Query.completions()); const scripts = projectWorkspace.getDocuments(Query.scripts()); expect(functions.length).toBeGreaterThan(2); expect(completions.length).toBeGreaterThan(1); expect(scripts.length).toBeGreaterThan(0); // Verify workspace analysis const workspace = projectWorkspace.getWorkspace(); expect(workspace?.allDocuments().length).toBeGreaterThan(5); }); }); describe('test 3', () => { TestWorkspace.create({ name: 'example_test3' }) .addFiles( TestFile.function('test3', ` function test3 echo "This is test 3" end`), TestFile.completion('test3', ` complete -c test3 -a "(ls)" complete -c test3 -l help -d "Show help"`), TestFile.config(` set -g fish_greeting "Welcome to test 3!" set -gx PATH $PATH /usr/local/test3/bin`), TestFile.confd('setup_test3', ` function setup_test3 --on-event fish_prompt if not set -q test3_loaded set -g test3_loaded true echo "Test 3 environment loaded" end end`), TestFile.custom('test3_script_1', ` echo "Running test 3 script..." set -gx file_path test3_script_1 `).withShebang(), TestFile.custom('test3_script_2', ` function run_2; echo "Running test 3 script..."; end`).withShebang(), TestFile.custom('test3_script_3', ` source ./test3_script_1 source ./test3_script_2 `).withShebang(), ) .setup(); it('should create all expected documents for test 3', () => { const docs = focusedWorkspace!.allDocuments(); expect(docs!.length).toBe(7); }); it('should find documents by simple path in test 3', () => { const test3Func = focusedWorkspace!.findDocument(d => d.uri.endsWith('functions/test3.fish')); expect(test3Func).toBeDefined(); expect(test3Func?.getText()).toContain('function test3'); }); it.only('show file tree', () => { const output: string[] = []; focusedWorkspace!.allDocuments().forEach(doc => { output.push(doc.getRelativeFilenameToWorkspace()); output.push(doc.getText()); output.push(doc.getTree()); }); const res = output.join('\n'); const fileTree = focusedWorkspace!.showAllTreeSitterParseTrees(); console.log(fileTree); expect(res).toContain('test3.fish'); expect(res).toContain('test3_script_1'); expect(res).toContain('test3_script_2'); expect(res).toContain('test3_script_3'); expect(res).not.toContain('test3_script_1.fish'); expect(res).not.toContain('test3_script_2.fish'); expect(res).not.toContain('test3_script_3.fish'); }); }); describe('test workspace src', () => { const testSrcWorkspace = TestWorkspace.create({ name: 'example_test_src' }) .addFiles( TestFile.function('src_test', ` function src_test echo "This is a src test function" end`), ).inheritFilesFromExistingAutoloadedWorkspace('$__fish_data_dir'); testSrcWorkspace.setup(); // setLogger(); it('should create all expected documents for src test', () => { const ws = focusedWorkspace!; // Array.from(ws!.allUris).forEach(uri => { // console.log(`URI: ${uri}`); // }) console.log(`len: ${ws?.allDocuments().length}`); testSrcWorkspace.addDocument( TestFile.function('src_test2', 'function src_test2; echo "This is src test 2"; end'), ); expect(testSrcWorkspace.documents.length).toBeGreaterThan(1); workspaceManager.setCurrent(testSrcWorkspace.getWorkspace()!); console.log(`workspaceManager: ${workspaceManager.current?.allDocuments().length}`); }); it('should create all expected documents for src test2', () => { const ws = testSrcWorkspace.getWorkspace(); // testSrcWorkspace.getDocuments.forEach(workspace => { // console.log(`Workspace: ${workspace.name}, Documents: ${workspace.documents.length}`); // }); // Array.from(ws!.allUris).forEach(uri => { // console.log(`URI: ${uri}`); // }) console.log(`len: ${ws?.allDocuments().length}`); expect(testSrcWorkspace.documents.length).toBeGreaterThan(1); workspaceManager.setCurrent(testSrcWorkspace.getWorkspace()!); console.log(`workspaceManager: ${workspaceManager.current?.allDocuments().length}`); testSrcWorkspace.writeSnapshot(); }); }); }); ================================================ FILE: tests/exec.test.ts ================================================ import { setLogger } from './helpers'; import * as path from 'path'; import { execEscapedCommand, execCmd, execCompleteLine, execCompleteSpace, execCommandDocs, execCommandType, ExecFishFiles, EmbeddedFishResult, } from '../src/utils/exec'; import { BuiltInList } from '../src/utils/builtins'; setLogger(); describe('src/utils/exec.ts tests', () => { it('execEscapedCommand', async () => { const output = await execEscapedCommand('pwd'); const check = path.resolve(__dirname, '..'); // const result = path.resolve(output.toString()) // console.log("escaped:", output[0], '---', check); expect(output[0]!).toEqual(check); }); it('execCmd', async () => { const output = await execCmd('pwd'); const check = path.resolve(__dirname, '..'); // console.log('execCmd: ', output[0], '---', check); expect(output[0]!).toEqual(check); }); it('execCompleteLine', async () => { const output = await execCompleteLine('echo -'); // console.log('line: ', output.length); expect(output.length).toEqual(4); }); it('execCompleteSpace', async () => { const output = await execCompleteSpace('string '); // console.log('line: ', output.length); expect(output.length).toEqual(17); }); it('execCommandDocs', async () => { const output = await execCommandDocs('end'); // console.log('docs: ', output.split('\n').length); expect(output.split('\n').length).toBeGreaterThan(10); }); it('execCommandType', async () => { const output = await execCommandType('end'); // console.log('docs: ', output.split('\n').length); expect(output).toEqual('command'); }); describe('ExecFishFiles namespace', () => { const expector = { pass: ({ stdout, stderr, code }: EmbeddedFishResult) => { expect(stdout.toString().length).toBeGreaterThan(0); expect(code).toEqual(0); expect(stderr.toString().length).toEqual(0); }, fail: ({ stdout, stderr, code }: EmbeddedFishResult) => { expect(stdout.toString().length).toEqual(0); expect(code).not.toEqual(0); expect(stderr.toString().length).toBeGreaterThan(0); }, }; let timesCalled = 0; const _logging = true; type PrintDocsParams = EmbeddedFishResult & { cmd?: string; verbose?: boolean; }; function printDocsStdout(input: PrintDocsParams) { const { stdout, stderr, code, verbose, cmd } = input; if (!_logging) return; if (timesCalled === 0) console.log('-------------------------'); timesCalled += 1; if (cmd) { console.log(`Documentation for command: \`${cmd}\``); } if (verbose) { console.log('=== VERBOSE OUTPUT ==='); console.log('--- stdout ---'); console.log(stdout); console.log('--- stderr ---'); console.log(stderr); console.log('--- code ---'); console.log(code); console.log('-------------------------'); } else { const totalLines = stdout.toString().split('\n').length; const firstLines = stdout.toString().split('\n').slice(0, 4).join('\n'); console.log('--- truncated stdout ---'); if (totalLines >= 4) { console.log([ firstLines, totalLines > 4 ? `+ ...... ${totalLines - 4} more lines.` : '', ].join('\n')); } else { console.log(stdout); } if (stderr.length > 0) { console.log('--- stderr ---'); console.log(stderr); } if (code !== null) { console.log('--- code ---'); console.log(code); } console.log('-------------------------'); } } describe('get-docs.fish', () => { it('base tests', async () => { console.log('Testing ExecFishFiles.getDocs for "echo"...'); const output = await ExecFishFiles.getDocs('echo'); printDocsStdout({ ...output, cmd: 'echo' }); expector.pass(output); const bgOutput = await ExecFishFiles.getDocs('bg'); // console.log('ExecFishFiles getCommandDoc: ', bgOutput.stdout.toString()); printDocsStdout({ ...bgOutput, cmd: 'bg' }); expector.pass(bgOutput); const testOutput = await ExecFishFiles.getDocs('['); // console.log('ExecFishFiles getCommandDoc: ', testOutput.stdout.toString()); printDocsStdout({ ...testOutput, cmd: '[' }); expector.pass(testOutput); const fkrOutput = await ExecFishFiles.getDocs('fish_key_reader'); // console.log('ExecFishFiles getCommandDoc: ', fkrOutput.stdout.toString()); printDocsStdout({ ...fkrOutput, cmd: 'fish_key_reader' }); expector.pass(fkrOutput); const nonExistOutput = await ExecFishFiles.getDocs('nonexistentcommand123'); printDocsStdout({ ...nonExistOutput, cmd: 'nonexistentcommand123' }); expector.fail(nonExistOutput); }); it('multiple commands `string match`, `git worktree`', async () => { console.log('Testing ExecFishFiles.getDocs for multiple commands (string-match, git-worktree)...'); const cmds = [ ['string', 'match'], ['git', 'worktree'], ]; for await (const args of cmds) { // console.log(`Testing ExecFishFiles.getDocs for \`${args[0]} ${args[1]}\`...`); const output = await ExecFishFiles.getDocs(...args); // console.log(`ExecFishFiles getCommandDoc for \`${args[0]} ${args.slice(1).join(' ')}\`: `, output.stdout.toString()); printDocsStdout({ ...output, cmd: `${args[0]} ${args.slice(1).join(' ')}` }); expector.pass(output); expect(output.stdout.toString().length).toBeGreaterThan(0); } }); it('builtin', async () => { console.log('Testing ExecFishFiles.getDocs for all built-in commands...'); const badCmds: string[] = []; await Promise.all(BuiltInList.map(async (cmd) => { const output = await ExecFishFiles.getDocs(cmd); printDocsStdout({ ...output, cmd }); expector.pass(output); if (output.stdout.toString().length === 0) badCmds.push(cmd); })); badCmds.forEach((cmd) => { console.error('ExecFishFiles getCommandDoc failed for command: ', cmd); }); expect(badCmds.length).toEqual(0); }); it('functions: __fish_contains_opt, fish_update_completions, fish_config', async () => { console.log('Testing ExecFishFiles.getDocs for fish functions(__fish_contains_opt, fish_update_completions, fish_config)...'); const functionCmds = ['__fish_contains_opt', 'fish_update_completions', 'fish_config']; for await (const cmd of functionCmds) { const output = await ExecFishFiles.getDocs(cmd); // console.log('ExecFishFiles getCommandDoc: ', cmd, '---', 'lines:', output.stdout.toString().split('\n').length); printDocsStdout({ ...output, cmd }); expect(output.stdout.toString().length).toBeGreaterThan(0); } // console.log(`Testing ExecFishFiles.getDocs for function \`${cmd}\`...`); }); it('commands', async () => { console.log('Testing ExecFishFiles.getDocs for fish commands...'); const out = await ExecFishFiles.getDocs('git'); // console.log('ExecFishFiles getCommandDoc: ', 'git', '---', out.stdout.toString()); printDocsStdout({ ...out, cmd: 'git' }); expector.pass(out); expect(out.stdout.toString().split('\n').at(3)!.trim().includes('git - the stupid content tracker')).toBeTruthy(); }); describe('edge cases', () => { it('empty command', async () => { console.log('Testing ExecFishFiles.getDocs for empty command...'); const output = await ExecFishFiles.getDocs(''); // console.log('ExecFishFiles getCommandDoc: ', '---', output.stdout.toString()); printDocsStdout({ ...output, cmd: '', verbose: true }); expect(output.stdout.toString().length).toEqual(0); expector.fail(output); }); it('command with flags', async () => { console.log('Testing ExecFishFiles.getDocs for `git --help` command...'); const output = await ExecFishFiles.getDocs('git', '--help'); // console.log('ExecFishFiles getCommandDoc: ', '---', output.stdout.toString()); printDocsStdout({ ...output, cmd: 'git --help' }); expect(output.stdout.toString().length).toBeGreaterThan(0); expect(output.code).toEqual(0); expector.pass(output); const passingOutput = await ExecFishFiles.getDocs('git', 'status'); printDocsStdout({ ...passingOutput, cmd: 'git status' }); expect(passingOutput.stdout.toString().length).toBeGreaterThan(0); expect(passingOutput.code).toEqual(0); expector.pass(output); }); it('variables as command', async () => { console.log('Testing ExecFishFiles.getDocs for `$HOME` command...'); const output = await ExecFishFiles.getDocs('$HOME'); // console.log('ExecFishFiles getCommandDoc: ', '---', output.stdout.toString()); printDocsStdout({ ...output, cmd: '$HOME', verbose: true }); expect(output.stdout.toString().length).toEqual(0); expector.fail(output); }); }); }); describe('getType', () => { it('basic tests', async () => { const commands = ['echo', 'set', 'function', 'for', 'if', 'end', 'cd', 'nonexistentcommand123']; for await (const cmd of commands) { const output = await ExecFishFiles.getType(cmd); printDocsStdout({ ...output, cmd }); if (cmd !== 'nonexistentcommand123') { expect(output.stdout.toString().trim()).toEqual('command'); } else { expect(output.stdout.toString().trim()).toEqual(''); expect(output.code).toEqual(0); } } }); it('function command', async () => { const functionCmds = ['__fish_contains_opt', 'fish_update_completions', 'fish_config']; for await (const cmd of functionCmds) { const output = await ExecFishFiles.getType(cmd); printDocsStdout({ ...output, cmd }); expect(output.stdout.toString().trim()).toEqual('file'); expect(output.code).toEqual(0); } }); }); }); }); ================================================ FILE: tests/execute-handler.test.ts ================================================ import { exec } from 'child_process'; import { promisify } from 'util'; import { buildOutput, execEntireBuffer, sourceFishBuffer, FishThemeDump, showCurrentTheme } from '../src/execute-handler'; import { setLogger } from './helpers'; import { execCmd } from '../src/utils/exec'; import { join } from 'path'; import { writeFileSync } from 'fs'; import { SyncFileHelper } from '../src/utils/file-operations'; const execAsync = promisify(exec); let content = [ 'function foo \\', ' --argument-names a b c', ' echo "\\$a:$a"', ' echo "\\$b:$b"', ' echo "\\$c:$c"', 'end', 'foo 1 2 3', ].join('\n'); // Define the file path let tmpBuff: string = join('/tmp', 'foo.fish'); setLogger( async () => { tmpBuff = join('/tmp', 'foo.fish'); }, ); describe('executeHandler tests', () => { // it('should find the longest line in a given set of strings', () => { // const longestLine = findLongestLine('short line', 'this is the longest line', 'medium line'); // expect(longestLine).toBe('this is the longest line'); // }); it('format message', async () => { const line = 'echo a b c d | string match -e \'b\''; const inputLine = `fish -c '${line}'`; const output = (await execCmd(inputLine)).join('\n'); const result = buildOutput(line, 'stdout:', output); // console.log({ formatOutput: output }); expect(output).toBe('a b c d'); }, 10000); it('format tmp buffer message', async () => { // Write the longest line to the file SyncFileHelper.write(tmpBuff, content, 'utf8'); const output = await execEntireBuffer(tmpBuff); // console.log({ entireBuff: output }); expect(output).toMatchObject({ message: '><(((°> executing file:\n' + ' /tmp/foo.fish\n' + '--------------------------------------------------\n' + '$a:1\n' + '$b:2\n' + '$c:3\n' + '--------------------------------------------------\n' + '$status: 0\n', kind: 'info', }); }, 10000); it('source file execution', async () => { // const parser = await initializeParser(); /** * Removes function call */ content = content.split('\n').slice(0, -1).join('\n').toString(); writeFileSync(tmpBuff, content, 'utf8'); const result = await sourceFishBuffer(tmpBuff); // console.log({ srcBuff: result }); expect(result).toBe( '><(((°> sourcing file:\n' + ' /tmp/foo.fish\n' + '--------------------------------------------------\n' + '$status: 0\n'); }, 10000); it('dump theme variables', async () => { content = '# I want to make a theme\n'; SyncFileHelper.create(tmpBuff); SyncFileHelper.write(tmpBuff, content); const nonStandardThemeContent = await FishThemeDump(); const functionTheme = SyncFileHelper.convertTextToFishFunction(tmpBuff, nonStandardThemeContent.join('\n')); // console.log(functionTheme); expect(functionTheme.uri).toBe('file:///tmp/foo.fish'); expect(functionTheme.getText()).toBeTruthy(); }, 10000); it('should source a Fish buffer and return the output message', async () => { const result = await sourceFishBuffer(tmpBuff); expect(result).toEqual(expect.any(String)); }, 10000); it('should show the current theme and append it to the buffer file', async () => { const result = await showCurrentTheme(tmpBuff); expect(result).toEqual({ message: '><(((°> appended theme variables to end of file', kind: 'info', }); }, 10000); }); ================================================ FILE: tests/file-operations.test.ts ================================================ import { SyncFileHelper, AsyncFileHelper } from '../src/utils/file-operations'; import { homedir } from 'os'; import { join } from 'path'; import { existsSync, unlinkSync, mkdirSync, rmdirSync, readFileSync, statSync } from 'fs'; import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import { pathToUri } from '../src/utils/translation'; import { setLogger } from './helpers'; import { vi } from 'vitest'; import { logger } from '../src/logger'; // Define a test directory and file paths const testDir = join(__dirname, 'fish_files'); const tildeTestDir = testDir.replace(process.env.HOME!, '~')!; const testFilePath = join(testDir, 'test_file.txt'); const testFilePathWithTilde = `${tildeTestDir}/test_file_tilde.txt`; setLogger(); // console.log({testDir, testFilePath, testFilePathWithTilde, tildeTestDir}); // Helper function to clean up test files const cleanUpTestFile = (filePath: string) => { if (existsSync(filePath)) { unlinkSync(filePath); } }; describe('SyncFileHelper', () => { beforeAll(() => { // Ensure the test directory exists if (!existsSync(testDir)) { fsPromises.mkdir(testDir, { recursive: true }); } }); afterAll(() => { // Clean up the test files after all tests cleanUpTestFile(testFilePath); cleanUpTestFile(testFilePathWithTilde.replace('~', process.env.HOME!)); }); it('should create a file if it does not exist', () => { const { path, filename, extension } = SyncFileHelper.create(testFilePath); expect(SyncFileHelper.exists(testFilePath)).toBe(true); expect(path).toBe(testFilePath); expect(filename).toBe('test_file'); expect(extension).toBe('txt'); }); it('should return path tokens for existing directory', () => { const result = SyncFileHelper.create(testDir); expect(result.path).toBe(testDir); expect(result.exists).toBe(true); expect(SyncFileHelper.isDirectory(result.path)).toBe(true); }); it('should write data to a file', () => { const data = 'Hello, world!'; SyncFileHelper.write(testFilePath, data); const readData = SyncFileHelper.read(testFilePath); expect(readData).toBe(data); }); it('should append data to a file', () => { const appendData = ' Appended text.'; SyncFileHelper.append(testFilePath, appendData); const readData = SyncFileHelper.read(testFilePath); expect(readData).toBe('Hello, world!' + appendData); }); it('should delete a file', () => { SyncFileHelper.delete(testFilePath); expect(SyncFileHelper.exists(testFilePath)).toBe(false); }); it('should expand tilde to home directory and create a file', () => { const expandedFilePath = testFilePathWithTilde.replace(/^~/, process.env.HOME!); const expandedDirFilePath = expandedFilePath.slice(0, expandedFilePath.lastIndexOf('/')); const { exists, extension, path, filename, directory } = SyncFileHelper.create(testFilePathWithTilde); expect(exists).toBe(true); expect(path).toBe(expandedFilePath); expect(directory).toBe(expandedDirFilePath); expect(filename).toBe('test_file_tilde'); expect(extension).toBe('txt'); }); it('test isDirectory working', () => { expect(SyncFileHelper.isDirectory(tildeTestDir)).toBe(true); expect(SyncFileHelper.isDirectory(testFilePathWithTilde)).toBe(false); expect(SyncFileHelper.isDirectory(testDir)).toBe(true); expect(SyncFileHelper.isDirectory(testFilePath)).toBe(false); }); it('should expand env variables', () => { const pathWithEnvVariable = '$HOME/.config/fish/config.fish'; const newPath = SyncFileHelper.expandEnvVars(pathWithEnvVariable); const expectedPath = `${homedir()}/.config/fish/config.fish`; expect(expectedPath).toBe(newPath); }); /* * it('test $fish_function_path works?', () => { * // `echo $fish_function_path` * // • Some documentation is available: * // >_ man -a fish-interactive # then scroll down to section: TAB COMPLETION * // # https://fishshell.com/docs/current/language.html#autoloading-functions * const pathWithEnvVariable = `$fish_function_path` * const newPath = SyncFileHelper.expandEnvVars(pathWithEnvVariable) * const expectedPath = `${homedir()}/.config/fish/functions/` * console.log(newPath); * // expect(expectedPath).toBe(newPath) * }) */ it('should convert file content to Fish function', () => { const data = 'echo "This is a test function."'; SyncFileHelper.convertTextToFishFunction(testFilePath, data); const expectedContent = '\nfunction test_file\n\techo "This is a test function."\nend'; const readData = SyncFileHelper.read(testFilePath); // console.log({ readData, expectedContent }); expect(readData).toBe(expectedContent); }); it('should append to existing file when converting to Fish function', () => { // Create an existing file first SyncFileHelper.write(testFilePath, 'existing content\n'); const data = 'echo "Appended function"'; const doc = SyncFileHelper.convertTextToFishFunction(testFilePath, data); const readData = SyncFileHelper.read(testFilePath); expect(readData).toContain('existing content'); expect(readData).toContain('\nfunction test_file\n\techo "Appended function"\nend'); expect(doc).toBeDefined(); expect(doc.languageId).toBe('txt'); // extension from test_file.txt }); it('should convert file content to TextDocumentItem', () => { const textDocItem = SyncFileHelper.toTextDocumentItem(testFilePath, 'plaintext', 1); expect(textDocItem.uri).toBe(pathToUri(testFilePath)); expect(textDocItem.languageId).toBe('plaintext'); expect(textDocItem.version).toBe(1); expect(textDocItem.text).toBe(SyncFileHelper.read(testFilePath)); }); it('should convert file content to LspDocument', () => { const lspDoc = SyncFileHelper.toLspDocument(testFilePath, 'plaintext', 1); expect(lspDoc.uri).toBe(pathToUri(testFilePath)); expect(lspDoc.languageId).toBe('plaintext'); expect(lspDoc.version).toBe(1); expect(lspDoc.getText()).toBe(SyncFileHelper.read(testFilePath)); }); it('should handle empty file when converting to LspDocument', () => { const emptyFilePath = join(testDir, 'empty_file.fish'); SyncFileHelper.write(emptyFilePath, ''); const lspDoc = SyncFileHelper.toLspDocument(emptyFilePath); expect(lspDoc.getText()).toBe(''); expect(lspDoc.languageId).toBe('fish'); // default language cleanUpTestFile(emptyFilePath); }); it('should handle non-existent file when converting to LspDocument', () => { logger.setSilent(true); const nonExistentPath = join(testDir, 'non-existent-for-lsp.fish'); const lspDoc = SyncFileHelper.toLspDocument(nonExistentPath); expect(lspDoc.getText()).toBe(''); expect(lspDoc.languageId).toBe('fish'); logger.setSilent(false); }); describe('expandNormalize', () => { it('should expand environment variables and normalize path', () => { const pathWithEnvVar = '$HOME/.config/fish/config.fish'; const result = SyncFileHelper.expandNormalize(pathWithEnvVar); const expected = `${homedir()}/.config/fish/config.fish`; expect(result).toBe(expected); }); it('should expand tilde and normalize path', () => { const pathWithTilde = '~/Documents/test.fish'; const result = SyncFileHelper.expandNormalize(pathWithTilde); const expected = `${process.env.HOME}/Documents/test.fish`; expect(result).toBe(expected); }); it('should normalize redundant separators', () => { const pathWithRedundantSeps = '/home//user///Documents/file.fish'; const result = SyncFileHelper.expandNormalize(pathWithRedundantSeps); expect(result).toBe('/home/user/Documents/file.fish'); }); it('should normalize . and .. in absolute paths', () => { const pathWithDots = '/home/user/./Documents/../Downloads/file.fish'; const result = SyncFileHelper.expandNormalize(pathWithDots); expect(result).toBe('/home/user/Downloads/file.fish'); }); it('should normalize . and .. in relative paths', () => { const pathWithDots = './foo/../bar/./baz.fish'; const result = SyncFileHelper.expandNormalize(pathWithDots); expect(result).toBe('bar/baz.fish'); }); it('should preserve relative path starting with ./', () => { const relativePath = './scripts/test.fish'; const result = SyncFileHelper.expandNormalize(relativePath); expect(result).toBe('scripts/test.fish'); // Note: path.normalize removes leading ./ when there are no other dots }); it('should preserve relative path starting with ../', () => { const relativePath = '../parent/file.fish'; const result = SyncFileHelper.expandNormalize(relativePath); expect(result).toBe('../parent/file.fish'); }); it('should handle complex path with env vars and normalization', () => { const complexPath = '$HOME/./Documents/../Downloads//file.fish'; const result = SyncFileHelper.expandNormalize(complexPath); const expected = `${process.env.HOME}/Downloads/file.fish`; expect(result).toBe(expected); }); it('should handle relative paths with env vars', () => { // Use an existing env var like HOME in a relative context // Note: $HOME expands to an absolute path like /home/user, // so ./subdir/$HOME becomes ./subdir/home/user which normalizes to subdir/home/user const pathWithEnvVar = './subdir/$HOME/file.fish'; const result = SyncFileHelper.expandNormalize(pathWithEnvVar); // After expansion: ./subdir//home/ndonfris/file.fish // After normalization: subdir/home/ndonfris/file.fish (removes ./ and //) const homeWithoutLeadingSlash = process.env.HOME!.replace(/^\//, ''); expect(result).toBe(`subdir/${homeWithoutLeadingSlash}/file.fish`); }); it('should preserve absolute path semantics', () => { const absolutePath = '/absolute/path/to/file.fish'; const result = SyncFileHelper.expandNormalize(absolutePath); expect(result).toBe('/absolute/path/to/file.fish'); }); it('should handle paths with multiple environment variables', () => { // Use HOME twice since it's available in the test environment // $HOME/subdir/$HOME/file.fish → /home/user/subdir//home/user/file.fish // After normalization: /home/user/subdir/home/user/file.fish (// → /) const pathWithMultipleEnvVars = '$HOME/subdir/$HOME/file.fish'; const result = SyncFileHelper.expandNormalize(pathWithMultipleEnvVars); const homeWithoutLeadingSlash = process.env.HOME!.replace(/^\//, ''); const expected = `${process.env.HOME}/subdir/${homeWithoutLeadingSlash}/file.fish`; expect(result).toBe(expected); }); it('should handle tilde with additional path components containing dots', () => { const pathWithTildeAndDots = '~/.config/../.local/./share/fish/config.fish'; const result = SyncFileHelper.expandNormalize(pathWithTildeAndDots); const expected = `${process.env.HOME}/.local/share/fish/config.fish`; expect(result).toBe(expected); }); it('should normalize trailing slashes', () => { const pathWithTrailingSlash = '/home/user/Documents/'; const result = SyncFileHelper.expandNormalize(pathWithTrailingSlash); // Note: path.normalize() preserves trailing slashes on Linux expect(result).toBe('/home/user/Documents/'); }); it('should handle empty path', () => { const emptyPath = ''; const result = SyncFileHelper.expandNormalize(emptyPath); expect(result).toBe('.'); }); it('should handle current directory', () => { const currentDir = '.'; const result = SyncFileHelper.expandNormalize(currentDir); expect(result).toBe('.'); }); it('should handle parent directory', () => { const parentDir = '..'; const result = SyncFileHelper.expandNormalize(parentDir); expect(result).toBe('..'); }); }); describe('open and close', () => { it('should open and close a file descriptor', () => { const fd = SyncFileHelper.open(testFilePath, 'r'); expect(typeof fd).toBe('number'); expect(fd).toBeGreaterThanOrEqual(0); SyncFileHelper.close(fd); }); it('should open file with expanded path', () => { const fd = SyncFileHelper.open(testFilePathWithTilde, 'r'); expect(typeof fd).toBe('number'); SyncFileHelper.close(fd); }); }); describe('loadDocumentSync', () => { it('should load a document from a file path', () => { SyncFileHelper.write(testFilePath, 'test content'); const doc = SyncFileHelper.loadDocumentSync(testFilePath); expect(doc).toBeDefined(); expect(doc?.getText()).toBe('test content'); expect(doc?.uri).toBe(pathToUri(testFilePath)); }); it('should return undefined for non-existent file', () => { const nonExistentPath = join(testDir, 'non-existent-file.fish'); const doc = SyncFileHelper.loadDocumentSync(nonExistentPath); expect(doc).toBeUndefined(); }); it('should return undefined for directory', () => { const doc = SyncFileHelper.loadDocumentSync(testDir); expect(doc).toBeUndefined(); }); it('should handle errors gracefully', () => { const originalConsoleLog = console.log; logger.setSilent(true); console.log = vi.fn(); // Mock console.log to suppress output during test const invalidPath = '/root/totally-inaccessible/file.fish'; const doc = SyncFileHelper.loadDocumentSync(invalidPath); expect(doc).toBeUndefined(); console.log = originalConsoleLog; // Restore original console.log logger.setSilent(false); }); // Note: The catch block in loadDocumentSync (lines 57-61) is defensive error handling // that's difficult to test without complex mocking of ES modules. // It handles unexpected errors during file reading that aren't caught by earlier checks. // The function is well-tested for all normal error paths (non-existent files, directories, etc.) }); describe('writeRecursive', () => { const recursiveTestDir = join(testDir, 'nested', 'deep', 'directory'); const recursiveTestFile = join(recursiveTestDir, 'test.fish'); afterAll(() => { // Clean up nested directories try { if (existsSync(recursiveTestFile)) unlinkSync(recursiveTestFile); if (existsSync(recursiveTestDir)) rmdirSync(recursiveTestDir); if (existsSync(join(testDir, 'nested', 'deep'))) rmdirSync(join(testDir, 'nested', 'deep')); if (existsSync(join(testDir, 'nested'))) rmdirSync(join(testDir, 'nested')); } catch (e) { // Ignore cleanup errors } }); it('should create directories recursively and write file', () => { const content = 'recursively written content'; SyncFileHelper.writeRecursive(recursiveTestFile, content); expect(SyncFileHelper.exists(recursiveTestFile)).toBe(true); expect(SyncFileHelper.read(recursiveTestFile)).toBe(content); }); it('should handle errors in writeRecursive gracefully', () => { logger.setSilent(true); // Try to write to an invalid location const invalidPath = '/root/cannot-write-here/file.fish'; expect(() => { SyncFileHelper.writeRecursive(invalidPath, 'content'); }).not.toThrow(); logger.setSilent(false); }); }); describe('read error cases', () => { it('should return empty string when reading a directory', () => { const content = SyncFileHelper.read(testDir); expect(content).toBe(''); }); it('should handle read errors gracefully', () => { logger.setSilent(true); const nonExistentFile = join(testDir, 'does-not-exist.fish'); const content = SyncFileHelper.read(nonExistentFile); expect(content).toBe(''); logger.setSilent(false); }); }); describe('isExpandable', () => { it('should return true for path with tilde', () => { expect(SyncFileHelper.isExpandable('~/test.fish')).toBe(true); }); it('should return true for path with env var', () => { expect(SyncFileHelper.isExpandable('$HOME/test.fish')).toBe(true); }); it('should return false for regular path', () => { expect(SyncFileHelper.isExpandable('/regular/path.fish')).toBe(false); }); it('should return false for empty expansion', () => { expect(SyncFileHelper.isExpandable('$NONEXISTENT_VAR')).toBe(false); }); }); describe('isFile', () => { it('should return true for existing file', () => { SyncFileHelper.write(testFilePath, 'content'); expect(SyncFileHelper.isFile(testFilePath)).toBe(true); }); it('should return false for directory', () => { expect(SyncFileHelper.isFile(testDir)).toBe(false); }); it('should return false for non-existent path', () => { expect(SyncFileHelper.isFile('/non/existent/path.fish')).toBe(false); }); }); describe('isWriteable methods', () => { it('should check if directory is writeable', () => { expect(SyncFileHelper.isWriteableDirectory(testDir)).toBe(true); }); it('should return false for non-existent directory', () => { expect(SyncFileHelper.isWriteableDirectory('/non/existent/dir')).toBe(false); }); it('should return false if path is file not directory', () => { SyncFileHelper.write(testFilePath, 'content'); expect(SyncFileHelper.isWriteableDirectory(testFilePath)).toBe(false); }); it('should check if file is writeable', () => { SyncFileHelper.write(testFilePath, 'content'); expect(SyncFileHelper.isWriteableFile(testFilePath)).toBe(true); }); it('should return false for non-existent file', () => { expect(SyncFileHelper.isWriteableFile('/non/existent/file.fish')).toBe(false); }); it('should return false if path is directory not file', () => { expect(SyncFileHelper.isWriteableFile(testDir)).toBe(false); }); it('should check if path is writeable (generic)', () => { expect(SyncFileHelper.isWriteable(testDir)).toBe(true); SyncFileHelper.write(testFilePath, 'content'); expect(SyncFileHelper.isWriteable(testFilePath)).toBe(true); }); it('should return false for non-writeable path', () => { expect(SyncFileHelper.isWriteable('/root/cannot-write.fish')).toBe(false); }); }); describe('isAbsolutePath and isRelativePath', () => { it('should identify absolute paths', () => { expect(SyncFileHelper.isAbsolutePath('/absolute/path.fish')).toBe(true); expect(SyncFileHelper.isAbsolutePath('~/home/path.fish')).toBe(true); }); it('should identify relative paths', () => { expect(SyncFileHelper.isRelativePath('./relative/path.fish')).toBe(true); expect(SyncFileHelper.isRelativePath('../parent/path.fish')).toBe(true); expect(SyncFileHelper.isRelativePath('relative/path.fish')).toBe(true); }); it('should handle paths with env vars', () => { expect(SyncFileHelper.isAbsolutePath('$HOME/path.fish')).toBe(true); expect(SyncFileHelper.isRelativePath('./path.fish')).toBe(true); }); }); }); describe('AsyncFileHelper', () => { const testDir = join(__dirname, 'fish_files'); const testFilePath = join(testDir, 'async_test_file.txt'); beforeAll(async () => { if (!existsSync(testDir)) { await fsPromises.mkdir(testDir, { recursive: true }); } }); afterAll(async () => { try { if (existsSync(testFilePath)) { await fsPromises.unlink(testFilePath); } } catch (e) { // Ignore cleanup errors } }); describe('isReadable', () => { it('should return true for readable file', async () => { await fsPromises.writeFile(testFilePath, 'content'); const result = await AsyncFileHelper.isReadable(testFilePath); expect(result).toBe(true); }); it('should return false for non-existent file', async () => { const result = await AsyncFileHelper.isReadable('/non/existent/file.fish'); expect(result).toBe(false); }); it('should expand env vars before checking', async () => { await fsPromises.writeFile(testFilePath, 'content'); const tildeTestPath = testFilePath.replace(process.env.HOME!, '~'); const result = await AsyncFileHelper.isReadable(tildeTestPath); expect(result).toBe(true); }); }); describe('isDir', () => { it('should return true for directory', async () => { const result = await AsyncFileHelper.isDir(testDir); expect(result).toBe(true); }); it('should return false for file', async () => { await fsPromises.writeFile(testFilePath, 'content'); const result = await AsyncFileHelper.isDir(testFilePath); expect(result).toBe(false); }); it('should return false for non-existent path', async () => { const result = await AsyncFileHelper.isDir('/non/existent/dir'); expect(result).toBe(false); }); }); describe('isFile', () => { it('should return true for file', async () => { await fsPromises.writeFile(testFilePath, 'content'); const result = await AsyncFileHelper.isFile(testFilePath); expect(result).toBe(true); }); it('should return false for directory', async () => { const result = await AsyncFileHelper.isFile(testDir); expect(result).toBe(false); }); it('should return false for non-existent path', async () => { const result = await AsyncFileHelper.isFile('/non/existent/file.fish'); expect(result).toBe(false); }); }); describe('readFile', () => { it('should read file content', async () => { const content = 'async test content'; await fsPromises.writeFile(testFilePath, content); const result = await AsyncFileHelper.readFile(testFilePath); expect(result).toBe(content); }); it('should read file with custom encoding', async () => { const content = 'async test content'; await fsPromises.writeFile(testFilePath, content); const result = await AsyncFileHelper.readFile(testFilePath, 'utf8'); expect(result).toBe(content); }); it('should expand env vars before reading', async () => { const content = 'async test content'; await fsPromises.writeFile(testFilePath, content); const tildeTestPath = testFilePath.replace(process.env.HOME!, '~'); const result = await AsyncFileHelper.readFile(tildeTestPath); expect(result).toBe(content); }); }); }); ================================================ FILE: tests/fish-symbol-fast-check.test.ts ================================================ import { describe, it, expect, beforeAll } from 'vitest'; import * as fc from 'fast-check'; import { SyntaxNode, Tree } from 'web-tree-sitter'; import { Analyzer } from '../src/analyze'; import { TestWorkspace, TestFile } from './test-workspace-utils'; import { LspDocument } from '../src/document'; import * as LSP from 'vscode-languageserver'; // Tree-sitter utilities import { getChildNodes, getRange, } from '../src/utils/tree-sitter'; // FishSymbol and related functionality import { FishSymbol, processNestedTree, filterLastPerScopeSymbol, findLocalLocations, // getGlobalSymbols, // getLocalSymbols, // isSymbol, formatFishSymbolTree, } from '../src/parsing/symbol'; import { flattenNested } from '../src/utils/flatten'; // Fish shell code generators for FishSymbol testing const fishSymbolArbitraries = { // Basic identifiers identifier: fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_]*$/), functionName: fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_-]*$/), variableName: fc.oneof( fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_]*$/), fc.constant('argv'), fc.constant('status'), fc.constant('PATH'), fc.constant('HOME'), fc.constant('USER'), ), commandName: fc.oneof( fc.constant('echo'), fc.constant('set'), fc.constant('test'), fc.constant('ls'), fc.constant('cat'), fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_-]*$/), ), stringValue: fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\n') && !s.includes("'")), option: fc.oneof( fc.stringMatching(/^-[a-zA-Z]$/), fc.stringMatching(/^--[a-zA-Z][a-zA-Z0-9-]*$/), ), path: fc.oneof( fc.constant('config.fish'), fc.constant('conf.d/aliases.fish'), fc.constant('functions/foo.fish'), fc.constant('completions/foo.fish'), fc.constant('/usr/share/fish/foo.fish'), fc.constant('script/foo'), fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_/.-]*\.fish$/), ), }; // Generators for different types of FishSymbol definitions const fishSymbolGenerators = { // Function definitions that create FUNCTION symbols functionDefinition: fc.tuple( fishSymbolArbitraries.functionName, fc.array(fishSymbolArbitraries.stringValue, { minLength: 0, maxLength: 3 }), fishSymbolArbitraries.path, ).map(([name, body, path]) => ({ code: `function ${name}\n${body.map(line => ` echo '${line}'`).join('\n')}\nend`, path, expectedSymbols: [{ name, kind: LSP.SymbolKind.Function, fishKind: 'FUNCTION' }], })), // Function with argument names that create ARGUMENT symbols functionWithArguments: fc.tuple( fishSymbolArbitraries.functionName, fc.array(fishSymbolArbitraries.identifier, { minLength: 1, maxLength: 4 }), fishSymbolArbitraries.path, ).map(([name, args, path]) => ({ code: `function ${name} --argument-names ${args.join(' ')}\n echo $${args[0]}\nend`, path, expectedSymbols: [ { name, kind: LSP.SymbolKind.Function, fishKind: 'FUNCTION' }, ...args.map(arg => ({ name: arg, kind: LSP.SymbolKind.Variable, fishKind: 'ARGUMENT' })), { name: 'argv', kind: LSP.SymbolKind.Variable, fishKind: 'ARGUMENT' }, ], })), // Set commands that create VARIABLE symbols setCommand: fc.tuple( fishSymbolArbitraries.variableName, fishSymbolArbitraries.stringValue, fc.oneof(fc.constant('-gx'), fc.constant('-x'), fc.constant('-l'), fc.constant('')), fishSymbolArbitraries.path, ).map(([name, value, flag, path]) => ({ code: `set ${flag} ${name} '${value}'`, path, expectedSymbols: [{ name, kind: LSP.SymbolKind.Variable, fishKind: 'SET' }], })), // For loops that create FOR symbols forLoop: fc.tuple( fishSymbolArbitraries.variableName, fc.array(fishSymbolArbitraries.stringValue, { minLength: 1, maxLength: 5 }), fishSymbolArbitraries.path, ).map(([varName, items, path]) => ({ code: `for ${varName} in ${items.map(i => `'${i}'`).join(' ')}\n echo $${varName}\nend`, path, expectedSymbols: [{ name: varName, kind: LSP.SymbolKind.Variable, fishKind: 'FOR' }], })), // Alias definitions that create ALIAS symbols aliasDefinition: fc.tuple( fishSymbolArbitraries.identifier, fishSymbolArbitraries.commandName, fishSymbolArbitraries.path, ).map(([alias, command, path]) => ({ code: `alias ${alias}='${command}'`, path, expectedSymbols: [{ name: alias, kind: LSP.SymbolKind.Function, fishKind: 'ALIAS' }], })), // Read commands that create READ symbols readCommand: fc.tuple( fishSymbolArbitraries.variableName, fishSymbolArbitraries.stringValue, fishSymbolArbitraries.path, ).map(([varName, input, path]) => ({ code: `echo '${input}' | read ${varName}`, path, expectedSymbols: [{ name: varName, kind: LSP.SymbolKind.Variable, fishKind: 'READ' }], })), // Argparse that creates ARGPARSE symbols argparseCommand: fc.tuple( fishSymbolArbitraries.functionName, fc.array(fishSymbolArbitraries.identifier, { minLength: 2, maxLength: 4 }), fishSymbolArbitraries.path, ).map(([funcName, options, path]) => ({ code: `function ${funcName}\n argparse ${options.map(opt => `'${opt}'`).join(' ')} -- $argv\n echo $_flag_${options[0]}\nend`, path, expectedSymbols: [ { name: funcName, kind: LSP.SymbolKind.Function, fishKind: 'FUNCTION' }, { name: 'argv', kind: LSP.SymbolKind.Variable, fishKind: 'ARGUMENT' }, ...options.flatMap(opt => [ { name: `_flag_${opt.charAt(0)}`, kind: LSP.SymbolKind.Variable, fishKind: 'ARGPARSE' }, { name: `_flag_${opt}`, kind: LSP.SymbolKind.Variable, fishKind: 'ARGPARSE' }, ]), ], })), // Complex nested function with multiple symbol types complexNested: fc.tuple( fishSymbolArbitraries.functionName, fishSymbolArbitraries.variableName, fc.array(fishSymbolArbitraries.identifier, { minLength: 2, maxLength: 3 }), fishSymbolArbitraries.path, ).map(([funcName, varName, args, path]) => ({ code: `function ${funcName} --argument-names ${args.join(' ')} set -l ${varName} (date +%s) for i in $argv echo $i end alias temp_alias='echo temp' end`, path, expectedSymbols: [ { name: funcName, kind: LSP.SymbolKind.Function, fishKind: 'FUNCTION' }, ...args.map(arg => ({ name: arg, kind: LSP.SymbolKind.Variable, fishKind: 'ARGUMENT' })), { name: 'argv', kind: LSP.SymbolKind.Variable, fishKind: 'ARGUMENT' }, { name: varName, kind: LSP.SymbolKind.Variable, fishKind: 'SET' }, { name: 'i', kind: LSP.SymbolKind.Variable, fishKind: 'FOR' }, { name: 'temp_alias', kind: LSP.SymbolKind.Function, fishKind: 'ALIAS' }, ], })), // Shebang script that creates local scope shebangScript: fc.tuple( fishSymbolArbitraries.functionName, fishSymbolArbitraries.variableName, fishSymbolArbitraries.stringValue, ).map(([funcName, varName, value]) => ({ code: `#!/usr/bin/env fish\nfunction ${funcName}\n echo 'hello'\nend\nset -l ${varName} '${value}'`, path: 'script/test', expectedSymbols: [ { name: funcName, kind: LSP.SymbolKind.Function, fishKind: 'FUNCTION' }, { name: 'argv', kind: LSP.SymbolKind.Variable, fishKind: 'ARGUMENT' }, { name: varName, kind: LSP.SymbolKind.Variable, fishKind: 'SET' }, ], })), }; describe('FishSymbol Fast-check Property Tests', () => { beforeAll(async () => { await Analyzer.initialize(); }); describe('FishSymbol Creation Properties', () => { it('should correctly identify function symbols and their properties', () => { fc.assert(fc.property(fishSymbolGenerators.functionDefinition, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have the expected number of function symbols const functionSymbols = flatSymbols.filter(s => s.fishKind === 'FUNCTION'); expect(functionSymbols.length).toBeGreaterThan(0); // Property: Function symbol should have correct properties const funcSymbol = functionSymbols[0]!; expect(funcSymbol.kind).toBe(LSP.SymbolKind.Function); expect(funcSymbol.fishKind).toBe('FUNCTION'); expect(typeof funcSymbol.name).toBe('string'); expect(funcSymbol.name.length).toBeGreaterThan(0); // Property: Function should have argv child for non-script files if (!testCase.path.includes('script/')) { const argvSymbols = flatSymbols.filter(s => s.name === 'argv'); expect(argvSymbols.length).toBeGreaterThan(0); } // Property: Function symbols should be properly scoped if (testCase.path.includes('config.fish') || testCase.path.includes('conf.d/')) { expect(funcSymbol.isGlobal()).toBe(true); } return true; } catch (error) { return true; } }), { numRuns: 30 }); }); it('should correctly handle function arguments and create ARGUMENT symbols', () => { fc.assert(fc.property(fishSymbolGenerators.functionWithArguments, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have ARGUMENT symbols for each argument const argumentSymbols = flatSymbols.filter(s => s.fishKind === 'ARGUMENT'); expect(argumentSymbols.length).toBeGreaterThan(0); // Property: All argument symbols should be local for (const argSymbol of argumentSymbols) { expect(argSymbol.isLocal()).toBe(true); expect(argSymbol.kind).toBe(LSP.SymbolKind.Variable); } // Property: Should have argv symbol const argvSymbol = flatSymbols.find(s => s.name === 'argv'); expect(argvSymbol).toBeDefined(); expect(argvSymbol!.fishKind).toBe('ARGUMENT'); return true; } catch (error) { return true; } }), { numRuns: 30 }); }); it('should correctly create VARIABLE symbols from set commands', () => { fc.assert(fc.property(fishSymbolGenerators.setCommand, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have SET symbols const setSymbols = flatSymbols.filter(s => s.fishKind === 'SET'); expect(setSymbols.length).toBeGreaterThan(0); // Property: SET symbols should have correct properties const setSymbol = setSymbols[0]!; expect(setSymbol.kind).toBe(LSP.SymbolKind.Variable); expect(setSymbol.fishKind).toBe('SET'); expect(typeof setSymbol.name).toBe('string'); // Property: Scope should be determined by flags and location const isGlobalFlag = testCase.code.includes('-gx') || testCase.code.includes('-x'); const isConfig = testCase.path.includes('config.fish') || testCase.path.includes('conf.d/'); if (isGlobalFlag && isConfig) { expect(setSymbol.isGlobal()).toBe(true); } return true; } catch (error) { return true; } }), { numRuns: 30 }); }); it('should correctly create FOR symbols from for loops', () => { fc.assert(fc.property(fishSymbolGenerators.forLoop, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have FOR symbols const forSymbols = flatSymbols.filter(s => s.fishKind === 'FOR'); expect(forSymbols.length).toBeGreaterThan(0); // Property: FOR symbols should have correct properties const forSymbol = forSymbols[0]!; expect(forSymbol.kind).toBe(LSP.SymbolKind.Variable); expect(forSymbol.fishKind).toBe('FOR'); expect(forSymbol.isLocal()).toBe(true); // Property: Scope node should be for_statement expect(forSymbol.scopeNode.type).toBe('for_statement'); return true; } catch (error) { return true; } }), { numRuns: 30 }); }); it('should correctly create ALIAS symbols', () => { fc.assert(fc.property(fishSymbolGenerators.aliasDefinition, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have ALIAS symbols const aliasSymbols = flatSymbols.filter(s => s.fishKind === 'ALIAS'); expect(aliasSymbols.length).toBeGreaterThan(0); // Property: ALIAS symbols should have correct properties const aliasSymbol = aliasSymbols[0]!; expect(aliasSymbol.kind).toBe(LSP.SymbolKind.Function); expect(aliasSymbol.fishKind).toBe('ALIAS'); // Property: Aliases should be global when in config files const isConfig = testCase.path.includes('config.fish') || testCase.path.includes('conf.d/'); if (isConfig) { expect(aliasSymbol.isGlobal()).toBe(true); } return true; } catch (error) { return true; } }), { numRuns: 30 }); }); it('should correctly create READ symbols', () => { fc.assert(fc.property(fishSymbolGenerators.readCommand, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have READ symbols const readSymbols = flatSymbols.filter(s => s.fishKind === 'READ'); expect(readSymbols.length).toBeGreaterThan(0); // Property: READ symbols should have correct properties const readSymbol = readSymbols[0]!; expect(readSymbol.kind).toBe(LSP.SymbolKind.Variable); expect(readSymbol.fishKind).toBe('READ'); return true; } catch (error) { return true; } }), { numRuns: 30 }); }); it('should correctly create ARGPARSE symbols', () => { fc.assert(fc.property(fishSymbolGenerators.argparseCommand, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Should have ARGPARSE symbols const argparseSymbols = flatSymbols.filter(s => s.fishKind === 'ARGPARSE'); expect(argparseSymbols.length).toBeGreaterThan(0); // Property: ARGPARSE symbols should have correct properties for (const argparseSymbol of argparseSymbols) { expect(argparseSymbol.kind).toBe(LSP.SymbolKind.Variable); expect(argparseSymbol.fishKind).toBe('ARGPARSE'); expect(argparseSymbol.name.startsWith('_flag_')).toBe(true); expect(argparseSymbol.isLocal()).toBe(true); } return true; } catch (error) { return true; } }), { numRuns: 30 }); }); }); describe('FishSymbol Relationship Properties', () => { it('should maintain correct parent-child relationships', () => { fc.assert(fc.property(fishSymbolGenerators.complexNested, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Function should have child symbols const functionSymbols = symbols.filter(s => s.fishKind === 'FUNCTION'); if (functionSymbols.length > 0) { const funcSymbol = functionSymbols[0]!; expect(funcSymbol.children.length).toBeGreaterThan(0); // Property: All children should have correct parent reference for (const child of funcSymbol.children) { expect(child.parent).toBe(funcSymbol); } } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly implement isBefore/isAfter relationships', () => { fc.assert(fc.property( fc.tuple(fishSymbolGenerators.aliasDefinition, fishSymbolGenerators.aliasDefinition), ([testCase1, testCase2]) => { try { const combinedCode = `${testCase1.code}\n${testCase2.code}`; const testWorkspace = TestWorkspace.createSingle(combinedCode, testCase1.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const aliasSymbols = flatSymbols.filter(s => s.fishKind === 'ALIAS'); if (aliasSymbols.length >= 2) { const [first, second] = aliasSymbols; // Property: First symbol should be before second expect(first!.isBefore(second!)).toBe(true); expect(second!.isAfter(first!)).toBe(true); expect(first!.isAfter(second!)).toBe(false); expect(second!.isBefore(first!)).toBe(false); } return true; } catch (error) { return true; } }, ), { numRuns: 20 }); }); it('should correctly implement equalScopes for symbols', () => { fc.assert(fc.property(fishSymbolGenerators.complexNested, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Symbols in the same scope should have equal scopes const localSymbols = flatSymbols.filter(s => s.isLocal()); if (localSymbols.length >= 2) { const [first, second] = localSymbols; if (first!.scopeNode.equals(second!.scopeNode)) { expect(first!.equalScopes(second!)).toBe(true); } } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); }); describe('FishSymbol Scope Properties', () => { it('should correctly identify global vs local symbols', () => { fc.assert(fc.property(fishSymbolGenerators.shebangScript, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Script symbols should be local for shebang scripts for (const symbol of flatSymbols) { if (symbol.fishKind === 'FUNCTION' && testCase.path.includes('script/')) { expect(symbol.isLocal()).toBe(true); expect(symbol.scopeTag).toBe('local'); } } // Property: Global and local symbols should be mutually exclusive for (const symbol of flatSymbols) { expect(symbol.isGlobal() && symbol.isLocal()).toBe(false); expect(symbol.isGlobal() || symbol.isLocal()).toBe(true); } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly determine scopeTag based on context', () => { const testCases = [ { code: 'set -gx FOO foo', path: 'config.fish', expectedGlobal: true }, { code: 'function foo\n set -l BAR bar\nend', path: 'config.fish', expectedLocal: true }, { code: '#!/usr/bin/env fish\nset FOO foo', path: 'script/test', expectedLocal: true }, ]; fc.assert(fc.property(fc.constantFrom(...testCases), (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const variableSymbols = flatSymbols.filter(s => s.fishKind === 'SET'); if (variableSymbols.length > 0) { const varSymbol = variableSymbols[0]!; if (testCase.expectedGlobal) { expect(varSymbol.isGlobal()).toBe(true); expect(varSymbol.scopeTag).toBe('global'); } if (testCase.expectedLocal) { expect(varSymbol.isLocal()).toBe(true); expect(varSymbol.scopeTag).toBe('local'); } } return true; } catch (error) { return true; } }), { numRuns: 15 }); }); }); describe('FishSymbol Conversion Properties', () => { it('should correctly convert to LSP Location', () => { fc.assert(fc.property(fishSymbolGenerators.functionDefinition, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); for (const symbol of flatSymbols.slice(0, 5)) { const location = symbol.toLocation(); // Property: Location should have valid URI expect(location.uri).toBe(doc.uri); // Property: Location should have valid range expect(location.range).toBeDefined(); expect(location.range.start.line).toBeGreaterThanOrEqual(0); expect(location.range.start.character).toBeGreaterThanOrEqual(0); expect(location.range.end.line).toBeGreaterThanOrEqual(location.range.start.line); } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly convert to WorkspaceSymbol', () => { fc.assert(fc.property(fishSymbolGenerators.functionDefinition, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const globalSymbols = flatSymbols.filter(s => s.isGlobal()); for (const symbol of globalSymbols) { const wsSymbol = symbol.toWorkspaceSymbol(); // Property: WorkspaceSymbol should have correct structure expect(wsSymbol.name).toBe(symbol.name); expect(wsSymbol.kind).toBe(symbol.kind); expect(wsSymbol.location.uri).toBe(doc.uri); expect(wsSymbol.location.range).toEqual(symbol.selectionRange); } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly convert to FoldingRange for functions', () => { fc.assert(fc.property(fishSymbolGenerators.functionDefinition, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const functionSymbols = flatSymbols.filter(s => s.fishKind === 'FUNCTION'); for (const funcSymbol of functionSymbols) { const foldingRange = funcSymbol.toFoldingRange(); // Property: FoldingRange should have valid structure expect(foldingRange.startLine).toBeGreaterThanOrEqual(0); expect(foldingRange.endLine).toBeGreaterThanOrEqual(foldingRange.startLine); expect(foldingRange.collapsedText).toBe(funcSymbol.name); expect(foldingRange.kind).toBe(LSP.FoldingRangeKind.Region); } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); }); describe('FishSymbol Utility Function Properties', () => { it('should correctly separate global and local symbols', () => { fc.assert(fc.property(fishSymbolGenerators.complexNested, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const globalSymbols = getGlobalSymbols(flatSymbols); const localSymbols = getLocalSymbols(flatSymbols); // Property: Global and local symbols should not overlap const globalNames = new Set(globalSymbols.map(s => `${s.name}-${s.scopeNode.id}`)); const localNames = new Set(localSymbols.map(s => `${s.name}-${s.scopeNode.id}`)); for (const name of globalNames) { expect(localNames.has(name)).toBe(false); } // Property: Combined should equal total expect(globalSymbols.length + localSymbols.length).toBe(flatSymbols.length); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly filter symbols by type with isSymbol', () => { fc.assert(fc.property(fishSymbolGenerators.complexNested, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: isSymbol should correctly filter by fishKind const functionSymbols = isSymbol(flatSymbols, 'FUNCTION'); const setSymbols = isSymbol(flatSymbols, 'SET'); const aliasSymbols = isSymbol(flatSymbols, 'ALIAS'); for (const symbol of functionSymbols) { expect(symbol.fishKind).toBe('FUNCTION'); } for (const symbol of setSymbols) { expect(symbol.fishKind).toBe('SET'); } for (const symbol of aliasSymbols) { expect(symbol.fishKind).toBe('ALIAS'); } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly find local locations for symbols', () => { fc.assert(fc.property(fishSymbolGenerators.functionDefinition, (testCase) => { try { // Create a test with function usage const testCode = `${testCase.code}\n${testCase.expectedSymbols[0]?.name || 'test_func'}`; const testWorkspace = TestWorkspace.createSingle(testCode, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const functionSymbols = flatSymbols.filter(s => s.fishKind === 'FUNCTION'); if (functionSymbols.length > 0) { const funcSymbol = functionSymbols[0]!; const locations = findLocalLocations(funcSymbol, flatSymbols); // Property: Should find at least the definition location expect(locations.length).toBeGreaterThanOrEqual(1); // Property: All locations should have valid ranges for (const location of locations) { expect(location.uri).toBe(doc.uri); expect(location.range.start.line).toBeGreaterThanOrEqual(0); expect(location.range.start.character).toBeGreaterThanOrEqual(0); } } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should correctly filter last per scope symbols', () => { fc.assert(fc.property( fc.array(fishSymbolGenerators.forLoop, { minLength: 2, maxLength: 4 }), (testCases) => { try { // Create multiple for loops with same variable name const combinedCode = testCases.map(tc => tc.code).join('\n'); const testWorkspace = TestWorkspace.createSingle(combinedCode, testCases[0]?.path || 'config.fish'); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const filteredSymbols = filterLastPerScopeSymbol(flatSymbols); // Property: Filtered symbols should be a subset of original expect(filteredSymbols.length).toBeLessThanOrEqual(flatSymbols.length); // Property: All filtered symbols should exist in original for (const filtered of filteredSymbols) { expect(flatSymbols.some(s => s.equals(filtered))).toBe(true); } return true; } catch (error) { return true; } }, ), { numRuns: 15 }); }); }); describe('FishSymbol Edge Cases and Error Handling', () => { it('should handle malformed Fish code gracefully', () => { const malformedCode = [ 'function\nend', // missing name 'for\nend', // missing variable 'set', // incomplete 'alias', // incomplete 'function foo\n# missing end', ]; fc.assert(fc.property( fc.oneof(...malformedCode.map(code => fc.constant(code))), (code) => { try { const testWorkspace = TestWorkspace.createSingle(code, 'config.fish'); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; // Property: Should not throw errors even with malformed code expect(() => { const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Test various operations getGlobalSymbols(flatSymbols); getLocalSymbols(flatSymbols); filterLastPerScopeSymbol(flatSymbols); for (const symbol of flatSymbols) { symbol.toLocation(); symbol.isGlobal(); symbol.isLocal(); } }).not.toThrow(); return true; } catch (error) { // Controlled failures are acceptable for malformed input return true; } }, ), { numRuns: 25 }); }); it('should maintain symbol equality consistency', () => { fc.assert(fc.property(fishSymbolGenerators.functionDefinition, (testCase) => { try { const testWorkspace = TestWorkspace.createSingle(testCase.code, testCase.path); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); // Property: Symbol should equal itself for (const symbol of flatSymbols) { expect(symbol.equals(symbol)).toBe(true); } // Property: Equality should be symmetric if (flatSymbols.length >= 2) { const [first, second] = flatSymbols; expect(first!.equals(second!)).toBe(second!.equals(first!)); } return true; } catch (error) { return true; } }), { numRuns: 20 }); }); }); describe('FishSymbol Performance Properties', () => { it('should handle large symbol trees efficiently', () => { fc.assert(fc.property( fc.array(fishSymbolGenerators.complexNested, { minLength: 5, maxLength: 15 }), (testCases) => { try { const startTime = Date.now(); const combinedCode = testCases.map(tc => tc.code).join('\n\n'); const testWorkspace = TestWorkspace.createSingle(combinedCode, 'config.fish'); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const symbols: FishSymbol[] = processNestedTree(doc, doc.tree.rootNode); const flatSymbols = flattenNested(...symbols); const processingTime = Date.now() - startTime; // Property: Processing should complete in reasonable time expect(processingTime).toBeLessThan(3000); // 3 seconds max // Property: Should handle large symbol counts expect(flatSymbols.length).toBeGreaterThan(0); // Property: Basic operations should complete without errors expect(() => { getGlobalSymbols(flatSymbols); getLocalSymbols(flatSymbols); filterLastPerScopeSymbol(flatSymbols); formatFishSymbolTree(symbols); }).not.toThrow(); return true; } catch (error) { return true; } }, ), { numRuns: 10 }); // Fewer runs for performance tests }); }); }); ================================================ FILE: tests/fish-symbol.test.ts ================================================ import * as os from 'os'; import { filterLastPerScopeSymbol, findLocalLocations, FishSymbol, processNestedTree } from '../src/parsing/symbol'; import * as LSP from 'vscode-languageserver'; import { setLogger, setupTestCallback, getAllTypesOfNestedArrays } from './helpers'; import { initializeParser } from '../src/parser'; import { flattenNested } from '../src/utils/flatten'; // import { LspDocument } from '../src/document'; import * as Parser from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; import { config } from '../src/config'; import { createArgparseCompletionsCodeAction, findFlagsToComplete } from '../src/code-actions/argparse-completions'; import { isCommand } from '../src/utils/node-types'; import { TextDocumentEdit } from 'vscode-languageserver'; let parser: Parser; let testBuilder: ReturnType; function getGlobalSymbols(symbols: FishSymbol[]): FishSymbol[] { return symbols.filter(s => s.isGlobal()); } function getLocalSymbols(symbols: FishSymbol[]): FishSymbol[] { return symbols.filter(s => s.isLocal()); } describe('`./src/parsing/**.ts` tests', () => { beforeAll(async () => { parser = await initializeParser(); }); beforeEach(() => { parser.reset(); testBuilder = setupTestCallback(parser); }); setLogger(); describe('building `FishSymbol[]`', () => { it('`config.fish` w/ `foo` function', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const nodes: SyntaxNode[] = flattenNested(root); expect(nodes.length).toBeGreaterThan(4); expect(doc.uri.endsWith('.config/fish/config.fish')).toBeTruthy(); const symbols: FishSymbol[] = processNestedTree(doc, root); expect(symbols).toHaveLength(1); expect(symbols[0]!.name).toBe('foo'); const flatSymbols = flattenNested(...symbols); expect(flatSymbols).toHaveLength(2); expect(flatSymbols[0]!.name).toBe('foo'); expect(flatSymbols[1]!.name).toBe('argv'); }); it('`config.fish` w/ `foo` function and `bar` function', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', 'function bar', " echo 'world'", 'end', ); expect(doc.isAutoloaded()).toBeTruthy(); const symbols: FishSymbol[] = processNestedTree(doc, root); expect(symbols).toHaveLength(2); expect(symbols[0]!.name).toBe('foo'); expect(symbols[1]!.name).toBe('bar'); const flatSymbols = flattenNested(...symbols); expect(flatSymbols).toHaveLength(4); expect(flatSymbols[0]!.name).toBe('foo'); expect(flatSymbols[1]!.name).toBe('bar'); expect(flatSymbols[2]!.name).toBe('argv'); expect(flatSymbols[3]!.name).toBe('argv'); }); it('`conf.d/foo.fish`', () => { const { doc, root } = testBuilder('conf.d/foo.fish', 'function _foo_1', " echo 'hello'", 'end', 'function _foo_2', " echo 'world'", 'end', 'function _foo', ' _foo_1 && _foo_2', 'end', 'set -gx FOO (_foo)', ); const symbols: FishSymbol[] = processNestedTree(doc, root); expect(symbols).toHaveLength(4); expect(symbols.map(s => s!.name)).toEqual(['_foo_1', '_foo_2', '_foo', 'FOO']); const flatSymbols = flattenNested(...symbols); expect(flatSymbols).toHaveLength(7); expect(flatSymbols.filter(s => s.name === 'argv')).toHaveLength(3); expect(flatSymbols.filter(s => s.kind === LSP.SymbolKind.Variable)).toHaveLength(4); expect(flatSymbols.filter(s => s.isGlobal())).toHaveLength(4); expect(flatSymbols.filter(s => s.isLocal())).toHaveLength(3); }); it('`script/shebang/foo`', () => { const { doc, root } = testBuilder('script/shebang/foo', '#!/usr/bin/env fish', 'function foo', " echo 'hello'", 'end', 'foo $argv', ); const { symbols, flatSymbols } = getAllTypesOfNestedArrays(doc, root); expect(symbols).toHaveLength(2); expect(flatSymbols).toHaveLength(3); expect(flatSymbols.filter(s => s.isGlobal())).toHaveLength(0); }); it('`config.fish` w/ more variable definitions', () => { const { doc, root } = testBuilder('config.fish', 'set -gx FOO foo', 'set -gx BAR bar', "echo 'baz' | read BAZ", 'function _my_func --argument-names first second third', ' echo $first', ' echo $second', ' echo $third', ' for arg in $argv', ' echo $arg', ' end', 'end', ); const { symbols, flatSymbols } = getAllTypesOfNestedArrays(doc, root); const variableSymbols = flatSymbols.filter(s => s.kind === LSP.SymbolKind.Variable); expect(symbols).toHaveLength(4); expect(variableSymbols).toHaveLength(8); expect(variableSymbols.filter(s => s.isGlobal())).toHaveLength(3); expect(variableSymbols.filter(s => s.isGlobal()).map(s => s.name)).toEqual(['FOO', 'BAR', 'BAZ']); expect(flatSymbols.filter(s => s.isLocal())).toHaveLength(5); }); it('`functions/foo.fish` w/ argparse', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo', ' argparse --stop-nonopt f/first s/second -- $argv', ' or return', ' echo $_flag_first', ' echo $_flag_second', 'end', ); const { symbols, flatSymbols } = getAllTypesOfNestedArrays(doc, root); expect(symbols).toHaveLength(1); expect(flatSymbols.filter(s => s.fishKind === 'ARGPARSE')).toHaveLength(4); expect(flatSymbols.filter(s => s.fishKind === 'ARGPARSE').map(s => s.name)).toEqual(['_flag_f', '_flag_first', '_flag_s', '_flag_second']); }); it('`conf.d/aliases.fish`', () => { const { doc, root } = testBuilder('conf.d/aliases.fish', "alias foo='echo foo'", "alias bar='echo bar'", ); const { symbols, flatSymbols } = getAllTypesOfNestedArrays(doc, root); expect(symbols).toHaveLength(2); expect(flatSymbols).toHaveLength(2); expect(flatSymbols.filter(s => s.fishKind === 'ALIAS')).toHaveLength(2); expect(flatSymbols.filter(s => s.fishKind === 'ALIAS').map(s => s.name)).toEqual(['foo', 'bar']); expect(flatSymbols.filter(s => s.isGlobal())).toHaveLength(2); }); }); describe('logging client tree', () => { function clientTree(symbol: FishSymbol[]) { function buildClientTree(indent: string = '', ...symbol: FishSymbol[]): string[] { const tree: string[] = []; for (const sym of symbol) { tree.push(`${indent}${sym.name}`); if (sym.children.length > 0) { tree.push(...buildClientTree(indent + ' ', ...sym.children)); } } return tree; } return buildClientTree('', ...symbol).join('\n'); } type NestedStringArray = Array; function expectedClientTree(names: NestedStringArray[]): string { function flattenNestedArrayToString(arr: NestedStringArray, indent = 0): string { return arr .map(item => { if (typeof item === 'string') { return ' '.repeat(indent * 2) + item; } else if (Array.isArray(item)) { return flattenNestedArrayToString(item, indent + 1); } return ''; }) .join('\n'); } return names .map(item => flattenNestedArrayToString(item)) .join('\n'); } it('config.fish client tree', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', 'function bar', " echo 'world'", 'end', ); const symbols: FishSymbol[] = processNestedTree(doc, root); const tree = clientTree(symbols); expect(tree).toBe(expectedClientTree([['foo', ['argv']], ['bar', ['argv']]])); }); it.skip('`config.fish` w/ duplicate definitions', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' set -l idx 1', ' for i in (seq 1 10)', ' echo $i', ' set idx (math $idx + 1)', ' end', 'end', ); const { symbols, flatSymbols } = getAllTypesOfNestedArrays(doc, root); expect(symbols).toBeDefined(); expect(flatSymbols).toBeDefined(); }); }); // describe('detail `FishSymbol[]`', () => { // it.skip('function definition detail', () => { // }); // // it.skip('variable definition detail', () => { // }); // // it.skip('argument definition detail', () => { // }); // // it.skip('alias definition detail', () => { // }); // }); describe('`FishSymbol` properties', () => { it('`FishSymbol.isGlobal()`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); expect(flatSymbols.filter(s => s.isGlobal())).toHaveLength(1); }); it('`FishSymbol.isLocal()`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); expect(flatSymbols.filter(s => s.isLocal())).toHaveLength(1); expect(flatSymbols.find(s => s.isLocal())!.name).toBe('argv'); }); // describe('`FishSymbol.isBefore()`/`FishSymbol.isAfter()`', () => { // it('foo before argv', () => { // const { doc, root } = testBuilder('config.fish', // 'function foo', // " echo 'hello'", // 'end', // ); // const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); // const fooFunction = flatSymbols.find(s => s.name === 'foo')!; // const argvVariable = flatSymbols.find(s => s.name === 'argv')!; // expect(fooFunction).toBeDefined(); // expect(argvVariable).toBeDefined(); // expect(fooFunction.isBefore(argvVariable)).toBeTruthy(); // expect(argvVariable.isAfter(fooFunction)).toBeTruthy(); // }); // // it('alias1 & alias2', () => { // const { doc, root } = testBuilder('config.fish', // "alias alias1='echo foo'", // "alias alias2='echo bar'", // ); // const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); // const alias1 = flatSymbols.find(s => s.name === 'alias1')!; // const alias2 = flatSymbols.find(s => s.name === 'alias2')!; // expect(alias1).toBeDefined(); // expect(alias2).toBeDefined(); // expect(alias1.isBefore(alias2)).toBeTruthy(); // expect(alias2.isAfter(alias1)).toBeTruthy(); // }); // // it('argparse', () => { // const { doc, root } = testBuilder('config.fish', // 'function foo', // ' argparse --stop-nonopt f/first s/second -- $argv', // ' or return', // ' echo $_flag_first', // ' echo $_flag_second', // 'end', // ); // const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); // const firstFlag = flatSymbols.find(s => s.name === '_flag_first')!; // const secondFlag = flatSymbols.find(s => s.name === '_flag_second')!; // expect(firstFlag).toBeDefined(); // expect(secondFlag).toBeDefined(); // expect(firstFlag.isBefore(secondFlag)).toBeTruthy(); // expect(secondFlag.isAfter(firstFlag)).toBeTruthy(); // }); // }); describe('`FishSymbol.equalScopes()`', () => { it('function foo && function bar', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', 'function bar', " echo 'world'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; const barFunction = flatSymbols.find(s => s.name === 'bar')!; expect(fooFunction).toBeDefined(); expect(barFunction).toBeDefined(); expect(fooFunction.equalScopes(barFunction)).toBeTruthy(); }); }); describe('`FishSymbol.toLocation()`', () => { it('function foo', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); const location = fooFunction.toLocation(); expect(location).toEqual({ uri: doc.uri, range: fooFunction.selectionRange, }); }); it('alias foo', () => { const { doc, root } = testBuilder('config.fish', "alias foo='echo foo'", ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooAlias = flatSymbols.find(s => s.name === 'foo')!; expect(fooAlias).toBeDefined(); const location = fooAlias.toLocation(); expect(location).toEqual({ uri: doc.uri, range: fooAlias.selectionRange, }); }); it.skip('argparse', () => { }); }); describe('`FishSymbol.toWorkspaceSymbol()`', () => { it('function foo', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const wsSymbols = flatSymbols.filter(s => s.isGlobal()).map(s => s.toWorkspaceSymbol()); expect(wsSymbols).toHaveLength(1); const fooSymbol = wsSymbols[0]!; expect(fooSymbol).toEqual({ name: 'foo', kind: LSP.SymbolKind.Function, location: { uri: doc.uri, range: flatSymbols[0]!.selectionRange, }, }); }); }); describe('`FishSymbol.isSymbolImmutable()`', () => { beforeEach(() => { config.fish_lsp_all_indexed_paths = [`${os.homedir()}/.config/fish`]; config.fish_lsp_modifiable_paths = [`${os.homedir()}/.config/fish`]; }); it('`config.fish`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo'); expect(fooFunction).toBeDefined(); expect(fooFunction!.isSymbolImmutable()).toBeFalsy(); }); it('`/usr/share/fish/foo.fish`', () => { const { doc, root } = testBuilder('/usr/share/fish/foo.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); expect(fooFunction.isSymbolImmutable()).toBeTruthy(); }); }); describe('`FishSymbol.toFoldingRange()`', () => { it('function foo', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); const foldingRange = fooFunction.toFoldingRange(); expect(foldingRange).toEqual({ startLine: 0, startCharacter: 0, endLine: 2, endCharacter: 3, collapsedText: 'foo', kind: LSP.FoldingRangeKind.Region, }); }); }); describe('`FishSymbol.equals()`', () => { it('function', () => { const { doc, root } = testBuilder('config.fish', 'function foo', " echo 'hello'", 'end', 'function bar', " echo 'world'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction1 = flatSymbols.find(s => s.name === 'foo')!; const fooFunction2 = flatSymbols.find(s => s.name === 'foo')!; const barFunction1 = flatSymbols.find(s => s.name === 'bar')!; expect(fooFunction1).toBeDefined(); expect(fooFunction2).toBeDefined(); expect(barFunction1).toBeDefined(); expect(fooFunction1.equals(fooFunction2)).toBeTruthy(); expect(fooFunction1.equals(barFunction1)).toBeFalsy(); }); it('alias', () => { const { doc, root } = testBuilder('config.fish', "alias foo='echo foo'", "alias bar='echo bar'", ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooAlias1 = flatSymbols.find(s => s.name === 'foo')!; const fooAlias2 = flatSymbols.find(s => s.name === 'foo')!; const barAlias1 = flatSymbols.find(s => s.name === 'bar')!; expect(fooAlias1).toBeDefined(); expect(fooAlias2).toBeDefined(); expect(barAlias1).toBeDefined(); expect(fooAlias1.equals(fooAlias2)).toBeTruthy(); expect(fooAlias1.equals(barAlias1)).toBeFalsy(); }); it('variables', () => { const { doc, root } = testBuilder('config.fish', 'set -gx FOO foo', 'set -gx BAR bar', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable1 = flatSymbols.find(s => s.name === 'FOO')!; const fooVariable2 = flatSymbols.find(s => s.name === 'FOO')!; const barVariable1 = flatSymbols.find(s => s.name === 'BAR')!; expect(fooVariable1).toBeDefined(); expect(fooVariable2).toBeDefined(); expect(barVariable1).toBeDefined(); expect(fooVariable1.equals(fooVariable2)).toBeTruthy(); expect(fooVariable1.equals(barVariable1)).toBeFalsy(); }); it('argparse', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' argparse --stop-nonopt f/first s/second -- $argv', ' or return', ' echo $_flag_first', ' echo $_flag_second', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const firstFlag1 = flatSymbols.find(s => s.name === '_flag_first')!; const firstFlag2 = flatSymbols.find(s => s.name === '_flag_first')!; const secondFlag1 = flatSymbols.find(s => s.name === '_flag_second')!; expect(firstFlag1).toBeDefined(); expect(firstFlag2).toBeDefined(); expect(secondFlag1).toBeDefined(); expect(firstFlag1.equals(firstFlag2)).toBeTruthy(); expect(firstFlag1.equals(secondFlag1)).toBeFalsy(); }); it('nested functions', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' function foo', " echo 'hello'", ' end', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunctions = flatSymbols.filter(s => s.name === 'foo')!; const fooFunctionOuter = fooFunctions[0]!; const fooFunctionInner = fooFunctions[1]!; expect(fooFunctionOuter).toBeDefined(); expect(fooFunctionInner).toBeDefined(); expect(fooFunctionOuter.equals(fooFunctionInner)).toBeFalsy(); }); }); describe('`FishSymbol.path()`', () => { it('`config.fish`', () => { const { doc, root } = testBuilder('config.fish', 'set -gx PATH $PATH /usr/bin', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'PATH')!; expect(fooFunction).toBeDefined(); expect(fooFunction.path).toEqual(`${os.homedir()}/.config/fish/config.fish`); }); it('`/usr/share/fish/foo.fish`', () => { const { doc, root } = testBuilder('/usr/share/fish/foo.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); expect(fooFunction.path).toEqual('/usr/share/fish/foo.fish'); }); }); describe('`FishSymbol.workspacePath()`', () => { it('`config.fish`', () => { const { doc, root } = testBuilder('config.fish', 'set -gx PATH $PATH /usr/bin', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'PATH')!; expect(fooFunction).toBeDefined(); expect(fooFunction.workspacePath).toEqual(`${os.homedir()}/.config/fish`); }); it('`/usr/share/fish/foo.fish`', () => { const { doc, root } = testBuilder('/usr/share/fish/foo.fish', 'function foo', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); expect(fooFunction.workspacePath).toEqual('/usr/share/fish'); }); it('`/usr/share/fish/functions/bar.fish`', () => { const { doc, root } = testBuilder('/usr/share/fish/functions/bar.fish', 'function bar', " echo 'hello'", 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const barFunction = flatSymbols.find(s => s.name === 'bar')!; expect(barFunction).toBeDefined(); expect(barFunction.workspacePath).toEqual('/usr/share/fish'); }); }); describe('`FishSymbol.scopeNode()`', () => { it('`config.fish`', () => { const { doc, root } = testBuilder('config.fish', 'set -gx PATH $PATH /usr/bin', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'PATH')!; expect(fooFunction).toBeDefined(); expect(fooFunction.scopeNode.type === 'program').toBeTruthy(); }); }); describe('`FishSymbol.scopeTag()`', () => { it('`config.fish`', () => { const { doc, root } = testBuilder('config.fish', 'set -gx PATH $PATH /usr/bin', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'PATH')!; expect(fooFunction).toBeDefined(); expect(fooFunction.scopeTag).toEqual('global'); }); }); }); describe('`FishSymbol` definition scope', () => { describe('FUNCTION', () => { it('`global`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' echo "hello"', 'end', 'set -gx PATH $PATH /usr/bin', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; const pathVariable = flatSymbols.find(s => s.name === 'PATH')!; expect(fooFunction).toBeDefined(); expect(pathVariable).toBeDefined(); expect(fooFunction.isGlobal()).toBeTruthy(); expect(fooFunction.scopeNode.type).toBe('program'); expect(fooFunction.scopeTag).toBe('global'); expect(fooFunction.scopeNode.equals(pathVariable.scopeNode)).toBeTruthy(); expect(fooFunction.scopeTag === pathVariable.scopeTag).toBeTruthy(); }); it('`local script`', () => { const { doc, root } = testBuilder('/home/username/script.fish', '#!/usr/bin/env fish', 'function foo', ' echo "hello"', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); expect(fooFunction.isLocal()).toBeTruthy(); expect(fooFunction.scopeNode.type).toBe('program'); expect(fooFunction.scopeTag).toBe('local'); }); it('nested `local`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' function bar', ' echo "hello"', ' end', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; const barFunction = flatSymbols.find(s => s.name === 'bar')!; expect(fooFunction).toBeDefined(); expect(barFunction).toBeDefined(); expect(fooFunction.isGlobal()).toBeTruthy(); expect(barFunction.isLocal()).toBeTruthy(); expect(fooFunction.scopeNode.type).toBe('program'); expect(fooFunction.scopeTag).toBe('global'); expect(barFunction.scopeNode.type).toBe('function_definition'); expect(barFunction.scopeNode.firstNamedChild!.text).toBe('foo'); expect(barFunction.scopeTag).toBe('local'); }); it('alias', () => { const { doc, root } = testBuilder('conf.d/aliases.fish', 'alias foo="echo foo"', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooAlias = flatSymbols.find(s => s.name === 'foo')!; expect(fooAlias).toBeDefined(); expect(fooAlias.isGlobal()).toBeTruthy(); expect(fooAlias.scopeNode.type).toBe('program'); expect(fooAlias.scopeTag).toBe('global'); }); it('alias local', () => { const { doc, root } = testBuilder('conf.d/aliases.fish', 'function foo', ' alias bar="echo foo"', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; const barAlias = flatSymbols.find(s => s.name === 'bar')!; expect(fooFunction).toBeDefined(); expect(barAlias).toBeDefined(); expect(fooFunction.scopeNode.type).toBe('program'); expect(fooFunction.scopeTag).toBe('global'); expect(barAlias.scopeNode.type).toBe('function_definition'); expect(barAlias.scopeTag).toBe('local'); }); }); describe('VARIABLE', () => { it('`global` config.fish', () => { const { doc, root } = testBuilder('config.fish', 'set -gx FOO foo', 'set -gx BAR bar', 'set -x BAZ baz', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable = flatSymbols.find(s => s.name === 'FOO')!; const barVariable = flatSymbols.find(s => s.name === 'BAR')!; const bazVariable = flatSymbols.find(s => s.name === 'BAZ')!; expect(fooVariable).toBeDefined(); expect(barVariable).toBeDefined(); expect(bazVariable).toBeDefined(); expect(fooVariable.isGlobal()).toBeTruthy(); expect(barVariable.isGlobal()).toBeTruthy(); expect(bazVariable.isGlobal()).toBeTruthy(); expect(fooVariable.scopeNode.type).toBe('program'); expect(barVariable.scopeNode.type).toBe('program'); expect(bazVariable.scopeNode.type).toBe('program'); }); it('`global` conf.d/vars.fish', () => { const { doc, root } = testBuilder('conf.d/vars.fish', 'set -gx FOO foo', 'set -gx BAR bar', 'set -x BAZ baz', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable = flatSymbols.find(s => s.name === 'FOO')!; const barVariable = flatSymbols.find(s => s.name === 'BAR')!; const bazVariable = flatSymbols.find(s => s.name === 'BAZ')!; expect(fooVariable).toBeDefined(); expect(barVariable).toBeDefined(); expect(bazVariable).toBeDefined(); expect(fooVariable.isGlobal()).toBeTruthy(); expect(barVariable.isGlobal()).toBeTruthy(); expect(bazVariable.isGlobal()).toBeTruthy(); expect(fooVariable.scopeNode.type).toBe('program'); expect(barVariable.scopeNode.type).toBe('program'); expect(bazVariable.scopeNode.type).toBe('program'); }); it('`local`', () => { const { doc, root } = testBuilder('functions/_foo.fish', 'function _foo', ' set -l FOO foo', ' set BAR $argv[1]', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable = flatSymbols.find(s => s.name === 'FOO')!; const barVariable = flatSymbols.find(s => s.name === 'BAR')!; expect(fooVariable).toBeDefined(); expect(barVariable).toBeDefined(); expect(fooVariable.isLocal()).toBeTruthy(); expect(barVariable.isLocal()).toBeTruthy(); expect(fooVariable.scopeNode.type).toBe('function_definition'); expect(barVariable.scopeNode.type).toBe('function_definition'); expect(fooVariable.scopeNode.equals(barVariable.scopeNode)).toBeTruthy(); }); // it.skip('nested `local`', () => { // }); it('for loop', () => { [ testBuilder('functions/_foo.fish', 'function _foo', ' for i in (seq 1 10)', ' set -l FOO foo', ' echo $i', ' end', 'end', ), testBuilder('conf.d/_foo.fish', 'for i in (seq 1 10)', ' echo $i', 'end', ), ].forEach(({ doc, root }, idx) => { const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const iVariable = flatSymbols.find(s => s.name === 'i')!; expect(iVariable).toBeDefined(); // if (idx === 0) { expect(iVariable.isLocal()).toBeTruthy(); expect(iVariable.scopeNode.type).toBe('for_statement'); // } else { // expect(iVariable.isGlobal()).toBeTruthy(); // expect(iVariable.scopeNode.type).toBe('program'); // } }); }); it('read `global`/`local`', () => { [ testBuilder('conf.d/_foo.fish', 'echo \'foo\' | read FOO', ), testBuilder('functions/_foo.fish', 'function _foo', ' echo $argv[1] | read FOO', 'end', ), ].forEach(({ doc, root }, idx) => { const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable = flatSymbols.find(s => s.name === 'FOO')!; expect(fooVariable).toBeDefined(); if (idx === 1) { expect(fooVariable.isLocal()).toBeTruthy(); expect(fooVariable.scopeNode.type).toBe('function_definition'); } else { expect(fooVariable.isGlobal()).toBeTruthy(); expect(fooVariable.scopeNode.type).toBe('program'); } }); }); it('argparse', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo', ' argparse --stop-nonopt f/first s/second -- $argv', ' or return', ' echo $_flag_first', ' echo $_flag_second', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const firstFlag = flatSymbols.find(s => s.name === '_flag_first')!; const secondFlag = flatSymbols.find(s => s.name === '_flag_second')!; expect(firstFlag).toBeDefined(); expect(secondFlag).toBeDefined(); expect(firstFlag.isLocal()).toBeTruthy(); expect(secondFlag.isLocal()).toBeTruthy(); expect(firstFlag.scopeNode.type).toBe('function_definition'); expect(secondFlag.scopeNode.type).toBe('function_definition'); }); it('argument-names', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo --argument-names first second third', ' echo $first', ' echo $second', ' echo $third', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const firstArg = flatSymbols.find(s => s.name === 'first')!; const secondArg = flatSymbols.find(s => s.name === 'second')!; const thirdArg = flatSymbols.find(s => s.name === 'third')!; expect(firstArg).toBeDefined(); expect(secondArg).toBeDefined(); expect(thirdArg).toBeDefined(); expect([firstArg, secondArg, thirdArg].filter(s => s.scopeTag === 'local')).toHaveLength(3); expect([firstArg, secondArg, thirdArg].filter(s => s.scopeNode.type === 'function_definition')).toHaveLength(3); }); it('argv', () => { [ testBuilder('functions/foo.fish', 'function foo --argument-names first second third', ' echo $first', ' echo $second', ' echo $third', 'end', ), testBuilder('script/foo', '#!/usr/bin/env fish', 'echo $argv', ), ].map(({ doc, root }, idx) => { const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const argv = flatSymbols.find(s => s.name === 'argv')!; expect(argv).toBeDefined(); expect(argv.isLocal()).toBeTruthy(); if (idx === 0) { expect(argv.scopeNode.type).toBe('function_definition'); } else if (idx === 1) { expect(argv.scopeNode.type).toBe('program'); } expect(argv.scopeTag).toBe('local'); }); }); }); }); describe('util functions', () => { it('`getLocalSymbols()`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' set -l FOO foo', ' set BAR $argv[1]', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const localSymbols = getLocalSymbols(flatSymbols); expect(localSymbols).toHaveLength(3); expect(localSymbols.map(s => s.name)).toEqual(['argv', 'FOO', 'BAR']); }); it('`getGlobalSymbols()`', () => { const { doc, root } = testBuilder('config.fish', 'set -gx FOO foo', 'set -gx BAR bar', 'set -x BAZ baz', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const globalSymbols = getGlobalSymbols(flatSymbols); expect(globalSymbols).toHaveLength(3); expect(globalSymbols.map(s => s.name)).toEqual(['FOO', 'BAR', 'BAZ']); }); it('`isSymbol()`', () => { const { doc, root } = testBuilder('config.fish', 'function foo', ' set -l FOO foo', ' set BAR $argv[1]', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable = flatSymbols.find(s => s.name === 'FOO')!; const barVariable = flatSymbols.find(s => s.name === 'BAR')!; expect(fooVariable).toBeDefined(); expect(barVariable).toBeDefined(); expect(flatSymbols.filter(s => s.fishKind === 'SET')).toHaveLength(2); }); describe('`filterLastPerScopeSymbol()`)', () => { it('global for loops', () => { const { doc, root } = testBuilder('config.fish', 'for i in (seq 1 10)', ' echo $i', 'end', 'for i in (seq 1 20)', ' echo $i', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const lastSymbols = filterLastPerScopeSymbol(flatSymbols); expect(lastSymbols).toHaveLength(2); }); it('local for loops', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo', ' for i in (seq 1 10)', ' echo $i', ' end', ' for i in (seq 1 20)', ' echo $i', ' end', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const lastSymbols = filterLastPerScopeSymbol(flatSymbols); expect(lastSymbols).toHaveLength(4); }); it('script for loops', () => { const { doc, root } = testBuilder('script/foo', '#!/usr/bin/env fish', 'for i in (seq 1 10)', ' echo $i', 'end', 'for i in (seq 1 20)', ' echo $i', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const lastSymbols = filterLastPerScopeSymbol(flatSymbols).filter(s => s.fishKind === 'FOR'); expect(lastSymbols).toHaveLength(2); }); it.skip('script variables', () => { const { doc, root } = testBuilder('script/foo', '#!/usr/bin/env fish', 'function __foo --argument-names FOO', ' echo $FOO', 'end', 'set -l FOO foo', '__foo $FOO', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const lastSymbols = filterLastPerScopeSymbol(flatSymbols); // console.log({ // all: flatSymbols.map(s => s.name), // last: lastSymbols.map(s => s.name), // }); expect(lastSymbols).toHaveLength(5); expect(lastSymbols.map(s => s.name)).toEqual(['argv', '__foo', 'FOO', 'argv', 'FOO']); }); }); }); describe('`FishSymbol` locations', () => { it('`function`', () => { const { doc, root } = testBuilder('script.fish', '#!/usr/bin/env fish', 'function foo', ' echo "hello"', 'end', 'foo $argv', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooFunction = flatSymbols.find(s => s.name === 'foo')!; expect(fooFunction).toBeDefined(); console.log({ locals: findLocalLocations(fooFunction, flatSymbols), all: flatSymbols.filter(s => s.name === 'foo'), }); const locals = findLocalLocations(fooFunction, flatSymbols); expect(locals).toHaveLength(2); }); it('`alias`', () => { const { doc, root } = testBuilder('script.fish', '#!/usr/bin/env fish', 'alias foo="echo \'foo\'"', 'foo', 'function foo', ' echo "hello"', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooAlias = flatSymbols.find(s => s.name === 'foo')!; expect(fooAlias).toBeDefined(); // console.log({ // locals: findLocalLocations(fooAlias, flatSymbols), // all: flatSymbols.filter(s => s.name === 'foo'), // }) // const locals = findLocalLocations(fooAlias, flatSymbols); expect(findLocalLocations(fooAlias, flatSymbols)).toHaveLength(3); }); it('`variable`', () => { const { doc, root } = testBuilder('script.fish', '#!/usr/bin/env fish', 'set -gx FOO foo', 'echo $FOO', 'function __util --argument-names FOO', ' set -l FOO foo', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const fooVariable = flatSymbols.find(s => s.name === 'FOO')!; expect(fooVariable).toBeDefined(); expect(findLocalLocations(fooVariable, flatSymbols)).toHaveLength(2); }); it('`argument`', () => { const { doc, root } = testBuilder('script.fish', '#!/usr/bin/env fish', 'function foo --argument-names first second', ' echo $first', ' echo $second', 'end', 'foo $argv', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const firstArg = flatSymbols.find(s => s.name === 'first')!; const secondArg = flatSymbols.find(s => s.name === 'second')!; expect(firstArg).toBeDefined(); expect(secondArg).toBeDefined(); expect(findLocalLocations(firstArg, flatSymbols)).toHaveLength(1); expect(findLocalLocations(secondArg, flatSymbols)).toHaveLength(1); }); it('`argparse`', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo', ' argparse --stop-nonopt f/first s/second -- $argv', ' or return', ' echo $_flag_first', ' echo $_flag_second', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const firstFlag = flatSymbols.find(s => s.name === '_flag_first')!; const secondFlag = flatSymbols.find(s => s.name === '_flag_second')!; expect(firstFlag).toBeDefined(); expect(secondFlag).toBeDefined(); // console.log(JSON.stringify(findLocalLocations(firstFlag, flatSymbols), null, 2)); expect(findLocalLocations(firstFlag, flatSymbols)).toHaveLength(2); expect(findLocalLocations(secondFlag, flatSymbols)).toHaveLength(2); const { doc: completionDoc, root: completionRoot } = testBuilder('completions/foo.fish', 'complete -c foo -s f -l first -d "first flag"', 'complete -c foo -s s -l second -d "second flag"', ); const { flatSymbols: completionSymbols } = getAllTypesOfNestedArrays(completionDoc, completionRoot); const firstCompletions = findLocalLocations(firstFlag, completionSymbols, false); const secondCompletions = findLocalLocations(secondFlag, completionSymbols, false); // console.log(JSON.stringify(completions, null, 2)); expect(firstCompletions).toHaveLength(1); expect(secondCompletions).toHaveLength(1); }); // test for [#136](https://github.com/ndonfris/fish-lsp/issues/136) // it('`argparse variable expansion in opt` (`argparse $opts -- $argv` prevent `_flag_$opts`)', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo', ' set -l options \'v/verbose\' ', ' argparse --stop-nonopt $options f/first s/second \'t/third=!_validate_int\' -- $argv', ' or return', ' echo $_flag_first', ' echo $_flag_second', ' echo $_flag_third', 'end', ); const { flatSymbols /** nodes */ } = getAllTypesOfNestedArrays(doc, root); const optionsVariable = flatSymbols.find(s => s.name === 'options' && s.fishKind === 'ARGPARSE'); /** * optionsVariable is expected to be undefined since we don't treat variable * expansion in `argparse` as defining the variable. */ expect(optionsVariable).toBeUndefined(); const mappedFlags = flatSymbols.map(s => [s.name, s.fishKind]); // mappedFlags.forEach((item) => { console.log(item); }); /** * make sure that the flags defined by `argparse` are still correctly identified * as `ARGPARSE` kind even if variable expansion is used in the `argparse` command. */ expect(mappedFlags).toEqual([ ['foo', 'FUNCTION'], ['argv', 'FUNCTION_VARIABLE'], ['options', 'SET'], // ['options', 'ARGPARSE'], /** Doesn't exist which is expected since we don't treat variable expansion in `argparse` as defining the variable. */ ['_flag_f', 'ARGPARSE'], ['_flag_first', 'ARGPARSE'], ['_flag_s', 'ARGPARSE'], ['_flag_second', 'ARGPARSE'], ['_flag_t', 'ARGPARSE'], ['_flag_third', 'ARGPARSE'], ]); }); }); describe('extra argparse tests', () => { it('`argparse` with variable expansion in options and flags', () => { const { doc, root } = testBuilder('functions/foo.fish', 'function foo', ' set -l options \'v/verbose\' ', ' argparse --stop-nonopt $options f/first s/second \'t/third=!_validate_int\' -- $argv', ' or return', 'end', ); const { flatSymbols } = getAllTypesOfNestedArrays(doc, root); const optionsVariable = flatSymbols.find(s => s.name === 'options' && s.fishKind === 'ARGPARSE'); const argparseFlags = flatSymbols.filter(s => s.fishKind === 'ARGPARSE'); expect(optionsVariable).toBeUndefined(); expect(argparseFlags).toHaveLength(6); const flagNames = argparseFlags.map(s => s.name); expect(flagNames).toEqual(['_flag_f', '_flag_first', '_flag_s', '_flag_second', '_flag_t', '_flag_third']); const allArgparseFlags = argparseFlags.map(s => ({ name: s.name, kind: s.fishKind })); expect(allArgparseFlags).toEqual([ { name: '_flag_f', kind: 'ARGPARSE' }, { name: '_flag_first', kind: 'ARGPARSE' }, { name: '_flag_s', kind: 'ARGPARSE' }, { name: '_flag_second', kind: 'ARGPARSE' }, { name: '_flag_t', kind: 'ARGPARSE' }, { name: '_flag_third', kind: 'ARGPARSE' }, ]); }); it('`argparse` with variable expansion inside string', () => { const { doc, root } = testBuilder('conf.d/foo.fish', 'function foo', ' set -l options \'v/verbose\' ', ' set -l normal_opts \'h/help\' \'d/debug\'', ' set -l validate_opts --min 1 --max 10', ' argparse --stop-nonopt "$options" \'$normal_opts\' f/first s/second \'t/third=!_validate_int $validate_opts\' -- $argv', ' or return', 'end', ); const { flatSymbols, nodes } = getAllTypesOfNestedArrays(doc, root); const argparseNode = nodes.find(n => isCommand(n) && n.firstNamedChild!.text === 'argparse')!; const argparseFlags = flatSymbols.filter(s => s.fishKind === 'ARGPARSE'); const allArgparseFlags = argparseFlags.map(s => ({ name: s.name, kind: s.fishKind })); // console.log({ allArgparseFlags }); expect(allArgparseFlags).toEqual([ { name: '_flag_f', kind: 'ARGPARSE' }, { name: '_flag_first', kind: 'ARGPARSE' }, { name: '_flag_s', kind: 'ARGPARSE' }, { name: '_flag_second', kind: 'ARGPARSE' }, { name: '_flag_t', kind: 'ARGPARSE' }, { name: '_flag_third', kind: 'ARGPARSE' }, ]); const ca = createArgparseCompletionsCodeAction(argparseNode, doc); expect(ca).toBeDefined(); // console.log(JSON.stringify({ ca }, null, 2)); const edit = ca!.edit!.documentChanges![0]! as TextDocumentEdit; const newText = edit.edits.map(e => e.newText).join(''); expect(newText).toBeDefined(); expect(newText).toContain('complete -c foo -s f -l first'); expect(newText).toContain('complete -c foo -s s -l second'); expect(newText).toContain('complete -c foo -s t -l third'); }); it('`argparse` with variable expansion in options and flags in conf.d file', () => { const { doc, root } = testBuilder('conf.d/foo.fish', 'function __foo_parse', ' set -l options \'v/verbose\' ', ' set -l validate_opts --min 1 --max 10', ' argparse --name=__foo_parse --stop-nonopt --min-args 1 --max-args=2 $options f/first s/second \'t/third=!_validate_int $validate_opts\' \'fourth=\' -- $argv', ' or return', 'end', ); const { flatSymbols, nodes } = getAllTypesOfNestedArrays(doc, root); const argparseNode = nodes.find(n => isCommand(n) && n.firstNamedChild!.text === 'argparse')!; const expectedFlags = findFlagsToComplete(argparseNode); expect(expectedFlags).toEqual([ { shortOption: 'f', longOption: 'first' }, { shortOption: 's', longOption: 'second' }, { shortOption: 't', longOption: 'third' }, { longOption: 'fourth' }, ]); const optionsVariable = flatSymbols.find(s => s.name === 'options' && s.fishKind === 'ARGPARSE'); const argparseFlags = flatSymbols.filter(s => s.fishKind === 'ARGPARSE'); expect(optionsVariable).toBeUndefined(); expect(argparseFlags).toHaveLength(7); expect(argparseFlags.map(s => s.name)).toEqual([ '_flag_f', '_flag_first', '_flag_s', '_flag_second', '_flag_t', '_flag_third', '_flag_fourth', ]); const codeAction = createArgparseCompletionsCodeAction(argparseNode, doc)!; expect(codeAction).toBeDefined(); const docEdits = codeAction.edit!.documentChanges! as TextDocumentEdit[]; const textEdits = docEdits.map(e => e.edits).flat().map(e => e.newText).join(''); expect(textEdits).toBeDefined(); expect(textEdits).toContain('complete -c __foo_parse -s f -l first'); expect(textEdits).toContain('complete -c __foo_parse -s s -l second'); expect(textEdits).toContain('complete -c __foo_parse -s t -l third'); expect(textEdits).toContain('complete -c __foo_parse -l fourth'); }); }); }); ================================================ FILE: tests/fish-syntax-node.test.ts ================================================ import * as Parser from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; // import {getReturnSiblings} from '../src/diagnostics/syntaxError'; import * as NodeTypes from '../src/utils/node-types'; import { getChildNodes } from '../src/utils/tree-sitter'; import { // logNodeSingleLine, resolveLspDocumentForHelperTestFile, setLogger, } from './helpers'; import { initializeParser } from '../src/parser'; // This file will be used to display what the expected output should be for the // tree-sitter parses. While the AST defined for fish shell is very helpful, the token // set required in for an LSP implementation, needs more strongly defined tokens. // We can see how this is problematic, in the following example: // // set -l var1 "hello world" // ^ ^ ^ ^-------------- double_quote_string // | | ------------------------ word // | ---------------------------- word // ------------------------------- command: [0,4] - [0, 25] // name: word [0, 0] [0, 3] // argument: word [0, 4] [0, 6] // argument: word [0, 7] [0, 11] // argument: double_quote_string [0, 12] [0, 25] // // Some data we want to be prepared to collect from the AST shown above, can be shown in the following example: // // 1. get the variable name. // - check if the name command has a parent which is a command // - check if the command has a firstNamedChild.text that is 'set' // - check if the first non-option ('-l' is the option) is the same node // that we are currently checking. // // 2. get the option(s) seen. // - here similarly we check that it is a command node, // - then we can also check that the node.text starts with '-' char, and is not an // actual '--' which would escape the command. (Example: string match -ra '\-.*' -- '-l') // // In this example, the checks are done through a series of very low computation time // lookups. All implementations, should do their best to use O(1) lookups that fail // fast, before checking the children nodes. // // Feel free to improve this file, as a reference for other developers. let SHOULD_LOG = false; // enable for verbose let parser: Parser; const jestConsole = console; const logger = setLogger( ); const loggingON = () => { SHOULD_LOG = true; }; // BEGIN TESTS describe('FISH web-tree-sitter SUITE', () => { beforeEach(async () => { parser = await initializeParser(); global.console = require('console'); }); afterEach(() => { global.console = jestConsole; SHOULD_LOG = false; }); it('test simple variable definitions', async () => { const test_variable_definitions = resolveLspDocumentForHelperTestFile('fish_files/simple/set_var.fish'); const root = parser.parse(test_variable_definitions.getText()).rootNode; const defs : SyntaxNode[] = []; const defNames: SyntaxNode[] = []; const vars : SyntaxNode[] = []; getChildNodes(root).forEach((node, idx) => { if (!node.isNamed) return; if (NodeTypes.isCommand(node)) defs.push(node); if (NodeTypes.isCommandName(node))defNames.push(node); if (NodeTypes.isVariableDefinition(node)) vars.push(node); return node; }); expect(defs.length === 1).toBeTruthy(); expect(defNames.length === 1).toBeTruthy(); expect(vars.length === 1).toBeTruthy(); if (SHOULD_LOG) [...defs, ...defNames, ...vars].forEach((node) => console.log(node)); }); it('test defined function', async () => { const test_doc = resolveLspDocumentForHelperTestFile('fish_files/simple/simple_function.fish'); const root = parser.parse(test_doc.getText()).rootNode; const funcs : SyntaxNode[] = []; const funcNames : SyntaxNode[] = []; getChildNodes(root).forEach((node, idx) => { if (!node.isNamed) return; if (NodeTypes.isFunctionDefinition(node)) funcs.push(node); if (NodeTypes.isFunctionDefinitionName(node)) funcNames.push(node); return node; }); expect(funcs.length === 1).toBeTruthy(); expect(funcNames.length === 1).toBeTruthy(); if (SHOULD_LOG) [...funcs, ...funcNames].forEach((node) => console.log('funcs vs funcName', node)); }); it('test defined function 2', async () => { const test_doc = resolveLspDocumentForHelperTestFile('fish_files/simple/function_variable_def.fish'); const root = parser.parse(test_doc.getText()).rootNode; const funcNames : SyntaxNode[] = []; const vars : SyntaxNode[] = []; getChildNodes(root).forEach((node, idx) => { if (!node.isNamed) return; if (NodeTypes.isFunctionDefinitionName(node)) funcNames.push(node); if (NodeTypes.isVariableDefinition(node)) vars.push(node); return node; }); expect(funcNames.length === 1).toBeTruthy(); expect(vars.length === 2).toBeTruthy(); if (SHOULD_LOG) [...vars].forEach((node) => console.log('function variable definitions', node)); }); it('test all variable def types ', async () => { const test_doc = resolveLspDocumentForHelperTestFile('fish_files/simple/all_variable_def_types.fish'); const parser = await initializeParser(); const root = parser.parse(test_doc.getText()).rootNode; const vars : SyntaxNode[] = []; getChildNodes(root).forEach((node, idx) => { if (!node.isNamed) return; if (NodeTypes.isVariableDefinition(node)) vars.push(node); return node; }); expect(vars.length).toEqual(8); expect(vars.map(n => n.text)).toEqual(['a', 'i', 'b', 'c', 'd', 'PATH', 'e', 'f']); if (SHOULD_LOG) [...vars].forEach((node) => console.log('function variable definitions', node)); }); // // [DEPRECATED] ... CURRENTLY UNKNOWN IMPORT CHANGES // //it("test is func_a", async () => { // loggingON(); // const parser = await initializeParser(); // const test_doc = resolveLspDocumentForHelperTestFile("fish_files/simple/func_a.fish", true); // const root = parser.parse(test_doc.getText()).rootNode; // const opts = getChildNodes(root) // .filter(node => NodeTypes.isDefinition(node)) // .map(node => { // return node.text + ' ' + findOptionString(node) // }) // console.log(opts); //}) //it("test is function_variable_def", async () => { // loggingON(); // const parser = await initializeParser(); // const test_doc = resolveLspDocumentForHelperTestFile("fish_files/simple/function_variable_def.fish", true); // const root = parser.parse(test_doc.getText()).rootNode; // const opts = getChildNodes(root) // .filter(node => NodeTypes.isDefinition(node)) // .map(node => { // return node.text + ' ' + findOptionString(node) // }) // console.log(opts); //}) }); ================================================ FILE: tests/fish_files/__fish_complete_docutils.fish ================================================ function __fish_complete_docutils -d "Completions for Docutils common options" -a cmd complete -x -c $cmd -k -a " ( __fish_complete_suffix .rst __fish_complete_suffix .txt ) " # General Docutils Options complete -c $cmd -l title -d "Specify the docs title" complete -c $cmd -s g -l generator -d "Include a generator credit" complete -c $cmd -l no-generator -d "Don't include a generator credit" complete -c $cmd -s d -l date -d "Include the date at the end of the docs" complete -c $cmd -s t -l time -d "Include the time and date" complete -c $cmd -l no-datestamp -d "Don't include a datestamp" complete -c $cmd -s s -l source-link -d "Include a source link" complete -c $cmd -l source-url -d "Use URL for a source link" complete -c $cmd -l no-source-link -d "Don't include a source link" complete -c $cmd -l toc-entry-backlinks -d "Link from section headers to TOC entries" complete -c $cmd -l toc-top-backlinks -d "Link from section headers to the top of the TOC" complete -c $cmd -l no-toc-backlinks -d "Disable backlinks to the TOC" complete -c $cmd -l footnote-backlinks -d "Link from footnotes/citations to references" complete -c $cmd -l no-footnote-backlinks -d "Disable backlinks from footnotes/citations" complete -c $cmd -l section-numbering -d "Enable section numbering" complete -c $cmd -l no-section-numbering -d "Disable section numbering" complete -c $cmd -l strip-comments -d "Remove comment elements" complete -c $cmd -l leave-comments -d "Leave comment elements" complete -c $cmd -l strip-elements-with-class -d "Remove all elements with classes" complete -c $cmd -l strip-class -d "Remove all classes attributes" complete -x -c $cmd -s r -l report -a "info warning error severe none 1 2 3 4 5" -d "Report system messages" complete -c $cmd -s v -l verbose -d "Report all system messages" complete -c $cmd -s q -l quiet -d "Report no system messages" complete -x -c $cmd -l halt -a "info warning error severe none 1 2 3 4 5" -d "Halt execution at system messages" complete -c $cmd -l strict -d "Halt at the slightest problem" complete -x -c $cmd -l exit-status -a "info warning error severe none 1 2 3 4 5" -d "Enable a non-zero exit status" complete -c $cmd -l debug -d "Enable debug output" complete -c $cmd -l no-debug -d "Disable debug output" complete -c $cmd -l warnings -d "File to output system messages" complete -c $cmd -l traceback -d "Enable Python tracebacks" complete -c $cmd -l no-traceback -d "Disable Python tracebacks" complete -c $cmd -s i -l input-encoding -d "Encoding of input text" complete -x -c $cmd -l input-encoding-error-handler -a "strict ignore replace" -d "Error handler" complete -c $cmd -s o -l output-encoding -d "Encoding for output" complete -x -c $cmd -l output-encoding-error-handler -a "strict ignore replace xmlcharrefreplace backslashreplace" -d "Error handler" complete -c $cmd -s e -l error-encoding -d "Encoding for error output" complete -x -c $cmd -l error-encoding-error-handler -d "Error handler" complete -c $cmd -s l -l language -d "Specify the language" complete -c $cmd -l record-dependencies -d "File to write output file dependencies" complete -c $cmd -l config -d "File to read configs" complete -c $cmd -s V -l version -d "Show version number" complete -c $cmd -s h -l help -d "Show help message" # reStructuredText Parser Options complete -c $cmd -l pep-references -d "Link to standalone PEP refs" complete -c $cmd -l pep-base-url -d "Base URL for PEP refs" complete -c $cmd -l pep-file-url-template -d "Template for PEP file part of URL" complete -c $cmd -l rfc-references -d "Link to standalone RFC refs" complete -c $cmd -l rfc-base-url -d "Base URL for RFC refs" complete -c $cmd -l tab-width -d "Specify tab width" complete -c $cmd -l trim-footnote-reference-space -d "Remove spaces before footnote refs" complete -c $cmd -l leave-footnote-reference-space -d "Leave spaces before footnote refs" complete -c $cmd -l no-file-insertion -d "Disable directives to insert file" complete -c $cmd -l file-insertion-enabled -d "Enable directives to insert file" complete -c $cmd -l no-raw -d "Disable the 'raw' directives" complete -c $cmd -l raw-enabled -d "Enable the 'raw' directives" complete -x -c $cmd -l syntax-highlight -a "long short none" -d "Token name set for Pygments" complete -x -c $cmd -l smart-quotes -a "yes no alt" -d "Change straight quotation marks" complete -c $cmd -l smartquotes-locales -d "'smart quotes' for the language" complete -c $cmd -l word-level-inline-markup -d "Inline markup at word level" complete -c $cmd -l character-level-inline-markup -d "Inline markup at character level" end function __fish_complete_docutils_standalone_reader -d "Completions for Docutils standalone reader options" -a cmd # Standalone Reader complete -c $cmd -l no-doc-title -d "Disable the docs title" complete -c $cmd -l no-doc-info -d "Disable the docs info" complete -c $cmd -l section-subtitles -d "Enable section subtitles" complete -c $cmd -l no-section-subtitles -d "Disable section subtitles" end function __fish_complete_docutils_html -d "Completions for Docutils HTML options" -a cmd # HTML-Specific Options complete -c $cmd -l template -d "Specify the template" complete -c $cmd -l stylesheet -d "List of stylesheet URLs" complete -c $cmd -l stylesheet-path -d "List of stylesheet paths" complete -c $cmd -l embed-stylesheet -d "Embed the stylesheets" complete -c $cmd -l link-stylesheet -d "Link to the stylesheets" complete -c $cmd -l stylesheet-dirs -d "List of directories where stylesheets are found" complete -x -c $cmd -l initial-header-level -a "1 2 3 4 5 6" -d "Specify the initial header level" if test $cmd != rst2html5 complete -c $cmd -l field-name-limit -d "Specify the limit for field names" complete -c $cmd -l option-limit -d "Specify the limit for options" end complete -x -c $cmd -l footnote-references -a "superscript brackets" -d "Format for footnote refs" complete -x -c $cmd -l attribution -a "dash parens none" -d "Format for block quote attr" complete -c $cmd -l compact-lists -d "Enable compact lists" complete -c $cmd -l no-compact-lists -d "Disable compact lists" complete -c $cmd -l compact-field-lists -d "Enable compact field lists" complete -c $cmd -l no-compact-field-lists -d "Disable compact field lists" if test $cmd = rst2html5 complete -x -c $cmd -l table-style -a "borderless booktabs align-left align-center align-right colwidths-auto" -d "Specify table style" else complete -x -c $cmd -l table-style -a borderless -d "Specify table style" end complete -x -c $cmd -l math-output -a "MathML HTML MathJax LaTeX" -d "Math output format" if test $cmd = rst2html5 complete -c $cmd -l xml-declaration -d "Prepend an XML declaration" end complete -c $cmd -l no-xml-declaration -d "Omit the XML declaration" complete -c $cmd -l cloak-email-addresses -d "Obfuscate email addresses" end function __fish_complete_docutils_latex -d "Completions for Docutils LaTeX options" -a cmd # LaTeX-Specific Options complete -c $cmd -l documentclass -d "Specify LaTeX documentclass" complete -c $cmd -l documentoptions -d "Specify docs options" complete -x -c $cmd -l footnote-references -a "superscript brackets" -d "Format for footnote refs" complete -x -c $cmd -l use-latex-citations -d "Use \cite command for citations" complete -x -c $cmd -l figure-citations -d "Use figure floats for citations" complete -x -c $cmd -l attribution -a "dash parens none" -d "Format for block quote attr" complete -c $cmd -l stylesheet -d "Specify LaTeX packages/stylesheets" complete -c $cmd -l stylesheet-path -d "List of LaTeX packages/stylesheets" complete -c $cmd -l link-stylesheet -d "Link to the stylesheets" complete -c $cmd -l embed-stylesheet -d "Embed the stylesheets" complete -c $cmd -l stylesheet-dirs -d "List of directories where stylesheets are found" complete -c $cmd -l latex-preamble -d "Customization the preamble" complete -c $cmd -l template -d "Specify the template" complete -c $cmd -l use-latex-toc -d "TOC by LaTeX" complete -c $cmd -l use-docutils-toc -d "TOC by Docutils" complete -c $cmd -l use-part-section -d "Add parts on top of the section hierarchy" complete -c $cmd -l use-docutils-docinfo -d "Use Docutils docinfo" complete -c $cmd -l use-latex-docinfo -d "Use LaTeX docinfo" complete -c $cmd -l topic-abstract -d "Typeset abstract as topic" complete -c $cmd -l use-latex-abstract -d "Use LaTeX abstract" complete -c $cmd -l hyperlink-color -d "Specify color of hyperlinks" complete -c $cmd -l hyperref-options -d "Additional options to the 'hyperref' package" complete -c $cmd -l compound-enumerators -d "Enable compound enumerators" complete -c $cmd -l no-compound-enumerators -d "Disable compound enumerators" complete -c $cmd -l section-prefix-for-enumerators -d "Enable section prefixes" complete -c $cmd -l no-section-prefix-for-enumerators -d "Disable section prefixes" complete -c $cmd -l section-enumerator-separator -d "Set the section enumerator separator" complete -c $cmd -l literal-block-env -d "Specify env for literal-blocks" complete -c $cmd -l use-verbatim-when-possible -d "Use 'verbatim' for literal-blocks" complete -x -c $cmd -l table-style -a "standard booktabs borderless" -d "Table style" complete -x -c $cmd -l graphicx-option -a "dvips pdftex auto" -d "LaTeX graphicx package option" if test $cmd = rst2latex complete -x -c $cmd -l font-encoding -a "T1 OT1 LGR,T1" -d "LaTeX font encoding" end complete -c $cmd -l reference-label -d "Puts the refs label" complete -c $cmd -l use-bibtex -d "Style and database for bibtex" complete -c $cmd -l docutils-footnotes -d "Footnotes by Docutils" end ================================================ FILE: tests/fish_files/__fish_complete_gpg.fish ================================================ # # Completions for the gpg program. # # This program accepts an rather large number of switches. It allows # you to do things like changing what file descriptor errors should be # written to, to make gpg use a different locale than the one # specified in the environment or to specify an alternative home # directory. # Switches related to debugging, switches whose use is not # recommended, switches whose behaviour is as of yet undefined, # switches for experimental features, switches to make gpg compliant # to legacy pgp-versions, dos-specific switches, switches meant for # the options file and deprecated or obsolete switches have all been # removed. The remaining list of completions is still quite # impressive. function __fish_complete_gpg -d "Internal function for gpg completion code deduplication" -a __fish_complete_gpg_command if string match -q 'gpg (GnuPG) 1.*' ($__fish_complete_gpg_command --version) complete -c $__fish_complete_gpg_command -l simple-sk-checksum -d 'Integrity protect secret keys by using a SHA-1 checksum' complete -c $__fish_complete_gpg_command -l no-sig-create-check -d "Do not verify each signature right after creation" complete -c $__fish_complete_gpg_command -l pgp2 -d "Set up all options to be as PGP 2.x compliant as possible" complete -c $__fish_complete_gpg_command -l rfc1991 -d "Try to be more RFC-1991 compliant" else complete -c $__fish_complete_gpg_command -l no-keyring -d "Do not use any keyring at all" complete -c $__fish_complete_gpg_command -l no-skip-hidden-recipients -d "During decryption, do not skip all anonymous recipients" complete -c $__fish_complete_gpg_command -l only-sign-text-ids -d "Exclude any non-text-based user ids from selection for signing" complete -c $__fish_complete_gpg_command -l override-session-key-fd -x -d "Don't use the public key but the specified session key" complete -c $__fish_complete_gpg_command -l passwd -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Change the passphrase of the secret key belonging to the given user id" complete -c $__fish_complete_gpg_command -l pinentry-mode -xa "default ask cancel error loopback" -d "Set the pinentry mode" complete -c $__fish_complete_gpg_command -l quick-add-key -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Directly add a subkey to a key" complete -c $__fish_complete_gpg_command -l quick-add-uid -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Adds a new user id to an existing key" complete -c $__fish_complete_gpg_command -l quick-gen-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Generate a standard key without needing to answer prompts" complete -c $__fish_complete_gpg_command -l quick-generate-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Generate a standard key without needing to answer prompts" complete -c $__fish_complete_gpg_command -l quick-lsign-key -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Directly sign a key from the passphrase; marks signatures as non-exportable" complete -c $__fish_complete_gpg_command -l quick-revoke-uid -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Revokes a user id on an existing key" complete -c $__fish_complete_gpg_command -l quick-revuid -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Revokes a user id on an existing key" complete -c $__fish_complete_gpg_command -l quick-set-expire -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Set the expiration time of the specified key" complete -c $__fish_complete_gpg_command -l quick-set-primary-uid -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Sets or updates the priary uid flag for the specified key" complete -c $__fish_complete_gpg_command -l quick-sign-key -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Directly sign a key from the passphrase" complete -c $__fish_complete_gpg_command -l receive-keys -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Import the keys with the given key IDs from a keyserver" complete -c $__fish_complete_gpg_command -s f -l recipient-file -r -d "Similar to --recipient, but encrypts to key stored in file instead" complete -c $__fish_complete_gpg_command -l request-origin -r -d "Tell gpg to assume that the operation ultimately originated at a particular origin" complete -c $__fish_complete_gpg_command -l sender -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "When creating a signature, tells gpg the user id of a key; when verifying, used to restrict information printed" complete -c $__fish_complete_gpg_command -l show-key -d "Take OpenPGP keys as input and print information about them" complete -c $__fish_complete_gpg_command -l show-keys -d "Take OpenPGP keys as input and print information about them" complete -c $__fish_complete_gpg_command -l skip-hidden-recipients -d "During decryption, skip all anonymous recipients" complete -c $__fish_complete_gpg_command -l tofu-default-policy -xa "auto good unknown bad ask" -d "Set the default TOFU policy" complete -c $__fish_complete_gpg_command -l tofu-policy -xa "auto good unknown bad ask" -d "Set the default TOFU policy for the specified keys" complete -c $__fish_complete_gpg_command -l try-secret-key -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command --list-secret-keys)" -d "Specify keys to be used for trial decryption" complete -c $__fish_complete_gpg_command -l with-icao-spelling -d "Print the ICAO spelling of the fingerprint in addition to the hex digits" complete -c $__fish_complete_gpg_command -l with-key-origin -d "Include the locally held information on the origin and last update of a key in a key listing" complete -c $__fish_complete_gpg_command -l with-keygrip -d "Include the keygrip in the key listings" complete -c $__fish_complete_gpg_command -l with-secret -d "Include info about the presence of a secret key in public key listings done with --with-colons" complete -c $__fish_complete_gpg_command -l no-symkey-cache -d "Disable the passphrase cache used for symmetrical en- and decryption" complete -c $__fish_complete_gpg_command -l no-autostart -d "Do not start the gpg-agent or the dirmngr if it has not been started and its service is required" complete -c $__fish_complete_gpg_command -l log-file -r -d "Write log output to the specified file" complete -c $__fish_complete_gpg_command -l locate-keys -d "Locate the keys given as arguments" complete -c $__fish_complete_gpg_command -l locate-external-keys -d "Locate they keys given as arguments; do not consider local keys" complete -c $__fish_complete_gpg_command -l list-signatures -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Same as --list-keys, but the signatures are listed too" complete -c $__fish_complete_gpg_command -l list-gcrypt-config -d "Display various internal configuration parameters of libgcrypt" complete -c $__fish_complete_gpg_command -l known-notation -r -d "Tell GnuPG about a critical signature notation" complete -c $__fish_complete_gpg_command -l key-origin -d "Track the origin of a key" complete -c $__fish_complete_gpg_command -l input-size-hint -r -d "Specify input size in bytes" complete -c $__fish_complete_gpg_command -s F -l hidden-recipient-file -r -d "Similar to --hidden-recipient, but encrypts to key stored in file instead" complete -c $__fish_complete_gpg_command -l generate-revocation -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Generate a revocation certificate for the complete key" complete -c $__fish_complete_gpg_command -l generate-key -d "Generate a new key pair" complete -c $__fish_complete_gpg_command -l generate-designated-revocation -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Generate a designated revocation certificate for a key" complete -c $__fish_complete_gpg_command -l full-gen-key -d "Generate a new key pair with dialogs for all options" complete -c $__fish_complete_gpg_command -l full-generate-key -d "Generate a new key pair with dialogs for all options" complete -c $__fish_complete_gpg_command -l export-filter -d "Define an import/export filter to apply to an imported/exported keyblock before it is written" complete -c $__fish_complete_gpg_command -l import-filter -d "Define an import/export filter to apply to an imported/exported keyblock before it is written" complete -c $__fish_complete_gpg_command -l edit-card -d "Present a menu to work with a smartcard" complete -c $__fish_complete_gpg_command -l disable-signer-uid -d "Don't embed the uid of the signing key in the data signature" complete -c $__fish_complete_gpg_command -l disable-dirmngr -d "Entirely disable the use of the Dirmngr" complete -c $__fish_complete_gpg_command -l dirmngr-program -r -d "Specify a dirmngr program to be used for keyserver access" complete -c $__fish_complete_gpg_command -l default-new-key-algo -x -d "Change the default algorithms for key generation" complete -c $__fish_complete_gpg_command -l compliance -xa "gnupg openpgp rfc4880 rfc4880bis rfc2440 pgp6 pgp7 pgp8" -d "Set a compliance standard for GnuPG" complete -c $__fish_complete_gpg_command -l change-passphrase -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Change the passphrase of the secret key belonging to the given user id" complete -c $__fish_complete_gpg_command -l agent-program -d "Specify an agent program to be used for secret key operations" complete -c $__fish_complete_gpg_command -l clear-sign -d "Make a clear text signature" end # # gpg subcommands # complete -c $__fish_complete_gpg_command -s s -l sign -d "Make a signature" complete -c $__fish_complete_gpg_command -l clearsign -d "Make a clear text signature" complete -c $__fish_complete_gpg_command -s b -l detach-sign -d "Make a detached signature" complete -c $__fish_complete_gpg_command -s e -l encrypt -d "Encrypt data" complete -c $__fish_complete_gpg_command -s c -l symmetric -d "Encrypt with a symmetric cipher using a passphrase" complete -c $__fish_complete_gpg_command -l store -d "Store only (make a simple literal data packet)" complete -c $__fish_complete_gpg_command -l decrypt -d "Decrypt specified file or stdin" complete -c $__fish_complete_gpg_command -l verify -d "Assume specified file or stdin is sigfile and verify it" complete -c $__fish_complete_gpg_command -l multifile -d "Modify certain other commands to accept multiple files for processing" complete -c $__fish_complete_gpg_command -l verify-files -d "Identical to '--multifile --verify'" complete -c $__fish_complete_gpg_command -l encrypt-files -d "Identical to '--multifile --encrypt'" complete -c $__fish_complete_gpg_command -l decrypt-files -d "Identical to --multifile --decrypt" complete -c $__fish_complete_gpg_command -s k -l list-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "List all keys from the public keyrings, or just the ones given on the command line" complete -c $__fish_complete_gpg_command -l list-public-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "List all keys from the public keyrings, or just the ones given on the command line" complete -c $__fish_complete_gpg_command -s K -l list-secret-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command --list-secret-keys)" -d "List all keys from the secret keyrings, or just the ones given on the command line" complete -c $__fish_complete_gpg_command -l list-sigs -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Same as --list-keys, but the signatures are listed too" complete -c $__fish_complete_gpg_command -l check-sigs -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Same as --list-keys, but the signatures are listed and verified" complete -c $__fish_complete_gpg_command -l check-signatures -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Same as --list-keys, but the signatures are listed and verified" complete -c $__fish_complete_gpg_command -l fingerprint -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "List all keys with their fingerprints" complete -c $__fish_complete_gpg_command -l gen-key -d "Generate a new key pair" complete -c $__fish_complete_gpg_command -l edit-key -d "Present a menu which enables you to do all key related tasks" -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" complete -c $__fish_complete_gpg_command -l sign-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Sign a public key with your secret key" complete -c $__fish_complete_gpg_command -l lsign-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Sign a public key with your secret key but mark it as non exportable" complete -c $__fish_complete_gpg_command -l delete-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Remove key from the public keyring" complete -c $__fish_complete_gpg_command -l delete-secret-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command --list-secret-keys)" -d "Remove key from the secret and public keyring" complete -c $__fish_complete_gpg_command -l delete-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Remove key from the public keyring" complete -c $__fish_complete_gpg_command -l delete-secret-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command --list-secret-keys)" -d "Remove key from the secret and public keyring" complete -c $__fish_complete_gpg_command -l delete-secret-and-public-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Same as --delete-key, but if a secret key exists, it will be removed first" complete -c $__fish_complete_gpg_command -l gen-revoke -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Generate a revocation certificate for the complete key" complete -c $__fish_complete_gpg_command -l design-revoke -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Generate a designated revocation certificate for a key" complete -c $__fish_complete_gpg_command -l export -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d 'Export all or the given keys from all keyrings' complete -c $__fish_complete_gpg_command -l export-ssh-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d 'Export all or the given keys in OpenSSH format' complete -c $__fish_complete_gpg_command -l send-keys -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Same as --export but sends the keys to a keyserver" complete -c $__fish_complete_gpg_command -l export-secret-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command --list-secret-keys)" -d "Same as --export, but exports the secret keys instead" complete -c $__fish_complete_gpg_command -l export-secret-subkeys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Same as --export, but exports the secret keys instead" complete -c $__fish_complete_gpg_command -l import -d 'Import/merge keys' complete -c $__fish_complete_gpg_command -l fast-import -d 'Import/merge keys' complete -c $__fish_complete_gpg_command -l recv-keys -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Import the keys with the given key IDs from a keyserver" complete -c $__fish_complete_gpg_command -l refresh-keys -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Request updates from a keyserver for keys that already exist on the local keyring" complete -c $__fish_complete_gpg_command -l search-keys -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Search the keyserver for the given names" complete -c $__fish_complete_gpg_command -l update-trustdb -d "Do trust database maintenance" complete -c $__fish_complete_gpg_command -l check-trustdb -d "Do trust database maintenance without user interaction" complete -c $__fish_complete_gpg_command -l export-ownertrust -d "Send the ownertrust values to stdout" complete -c $__fish_complete_gpg_command -l import-ownertrust -d "Update the trustdb with the ownertrust values stored in specified files or stdin" complete -c $__fish_complete_gpg_command -l rebuild-keydb-caches -d "Create signature caches in the keyring" complete -c $__fish_complete_gpg_command -l print-md -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Hash)" -d "Print message digest of specified algorithm for all given files or stdin" complete -c $__fish_complete_gpg_command -l print-mds -d "Print message digest of all algorithms for all given files or stdin" complete -c $__fish_complete_gpg_command -l gen-random -xa "0 1 2" -d "Emit specified number of random bytes of the given quality level" complete -c $__fish_complete_gpg_command -l card-edit -d "Present a menu to work with a smartcard" complete -c $__fish_complete_gpg_command -l card-status -x -d "Print smartcard status" complete -c $__fish_complete_gpg_command -l change-pin -x -d "Change smartcard PIN" complete -c $__fish_complete_gpg_command -l version -d "Display version and supported algorithms, and exit" complete -c $__fish_complete_gpg_command -l warranty -d "Display warranty and exit" complete -c $__fish_complete_gpg_command -s h -l help -d "Display help and exit" # # gpg options # complete -c $__fish_complete_gpg_command -s a -l armor -d "Create ASCII armored output" complete -c $__fish_complete_gpg_command -s o -l output -r -d "Write output to specified file" complete -c $__fish_complete_gpg_command -l max-output -d "Sets a limit on the number of bytes that will be generated when processing a file" -x complete -c $__fish_complete_gpg_command -s u -l local-user -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Use specified key as the key to sign with" complete -c $__fish_complete_gpg_command -l default-key -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Use specified key as the default key to sign with" complete -c $__fish_complete_gpg_command -s r -l recipient -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Encrypt for specified user id" complete -c $__fish_complete_gpg_command -s R -l hidden-recipient -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Encrypt for specified user id, but hide the keyid of the key" complete -c $__fish_complete_gpg_command -l default-recipient -xa "(__fish_complete_gpg_user_id $__fish_complete_gpg_command)" -d "Use specified user id as default recipient" complete -c $__fish_complete_gpg_command -l default-recipient-self -d "Use the default key as default recipient" complete -c $__fish_complete_gpg_command -l no-default-recipient -d "Reset --default-recipient and --default-recipient-self" complete -c $__fish_complete_gpg_command -s v -l verbose -d "Give more information during processing" complete -c $__fish_complete_gpg_command -s q -l quiet -d "Quiet mode" complete -c $__fish_complete_gpg_command -s z -d "Compression level" -xa "(seq 1 9)" complete -c $__fish_complete_gpg_command -l compress-level -d "Compression level" -xa "(seq 1 9)" complete -c $__fish_complete_gpg_command -l bzip2-compress-level -d "Compression level" -xa "(seq 1 9)" complete -c $__fish_complete_gpg_command -l bzip2-decompress-lowmem -d "Use a different decompression method for BZIP2 compressed files" complete -c $__fish_complete_gpg_command -s t -l textmode -d "Treat input files as text and store them in the OpenPGP canonical text form with standard 'CRLF' line endings" complete -c $__fish_complete_gpg_command -l no-textmode -d "Don't treat input files as text and store them in the OpenPGP canonical text form with standard 'CRLF' line endings" complete -c $__fish_complete_gpg_command -s n -l dry-run -d "Don't make any changes (this is not completely implemented)" complete -c $__fish_complete_gpg_command -s i -l interactive -d "Prompt before overwrite" complete -c $__fish_complete_gpg_command -l batch -d "Batch mode" complete -c $__fish_complete_gpg_command -l no-batch -d "Don't use batch mode" complete -c $__fish_complete_gpg_command -l no-tty -d "Never write output to terminal" complete -c $__fish_complete_gpg_command -l yes -d "Assume yes on most questions" complete -c $__fish_complete_gpg_command -l no -d "Assume no on most questions" complete -c $__fish_complete_gpg_command -l ask-cert-level -d "Prompt for a certification level when making a key signature" complete -c $__fish_complete_gpg_command -l no-ask-cert-level -d "Don't prompt for a certification level when making a key signature" complete -c $__fish_complete_gpg_command -l default-cert-level -xa "0\t'Not verified' 1\t'Not verified' 2\t'Caual verification' 3\t'Extensive verification'" -d "The default certification level to use for the level check when signing a key" complete -c $__fish_complete_gpg_command -l min-cert-level -xa "0 1 2 3" -d "Disregard any signatures with a certification level below specified level when building the trust database" complete -c $__fish_complete_gpg_command -l trusted-key -xa "(__fish_complete_gpg_key_id $__fish_complete_gpg_command)" -d "Assume that the specified key is as trustworthy as one of your own secret keys" complete -c $__fish_complete_gpg_command -l trust-model -xa "pgp classic direct always" -d "Specify trust model" complete -c $__fish_complete_gpg_command -l keyid-format -xa "short 0xshort long 0xlong" -d "Select how to display key IDs" complete -c $__fish_complete_gpg_command -l keyserver -x -d "Use specified keyserver" complete -c $__fish_complete_gpg_command -l keyserver-options -xa "(__fish_append , include-revoked include-disabled honor-keyserver-url include-subkeys use-temp-files keep-temp-files verbose timeout http-proxy auto-key-retrieve)" -d "Options for the keyserver" complete -c $__fish_complete_gpg_command -l import-options -xa "(__fish_append , import-local-sigs repair-pks-subkey-bug merge-only)" -d "Options for importing keys" complete -c $__fish_complete_gpg_command -l export-options -xa "(__fish_append , export-local-sigs export-attributes export-sensitive-revkeys export-minimal)" -d "Options for exporting keys" complete -c $__fish_complete_gpg_command -l list-options -xa "(__fish_append , show-photos show-policy-urls show-notations show-std-notations show-user-notations show-keyserver-urls show-uid-validity show-unusable-uids show-unusable-subkeys show-keyring show-sig-expire show-sig-subpackets )" -d "Options for listing keys and signatures" complete -c $__fish_complete_gpg_command -l verify-options -xa "(__fish_append , show-photos show-policy-urls show-notations show-std-notations show-user-notations show-keyserver-urls show-uid-validity show-unusable-uids)" -d "Options for verifying signatures" complete -c $__fish_complete_gpg_command -l photo-viewer -r -d "The command line that should be run to view a photo ID" complete -c $__fish_complete_gpg_command -l exec-path -r -d "Sets a list of directories to search for photo viewers and keyserver helpers" complete -c $__fish_complete_gpg_command -l show-keyring -d "Display the keyring name at the head of key listings to show which keyring a given key resides on" complete -c $__fish_complete_gpg_command -l keyring -r -d "Add specified file to the current list of keyrings" complete -c $__fish_complete_gpg_command -l secret-keyring -r -d "Add specified file to the current list of secret keyrings" complete -c $__fish_complete_gpg_command -l primary-keyring -r -d "Designate specified file as the primary public keyring" complete -c $__fish_complete_gpg_command -l trustdb-name -r -d "Use specified file instead of the default trustdb" complete -c $__fish_complete_gpg_command -l homedir -xa "(__fish_complete_directories (commandline -ct))" -d "Set the home directory" complete -c $__fish_complete_gpg_command -l display-charset -xa " iso-8859-1 iso-8859-2 iso-8859-15 koi8-r utf-8 " -d "Set the native character set" complete -c $__fish_complete_gpg_command -l utf8-strings -d "Assume that following command line arguments are given in UTF8" complete -c $__fish_complete_gpg_command -l no-utf8-strings -d "Assume that following arguments are encoded in the character set specified by --display-charset" complete -c $__fish_complete_gpg_command -l options -r -d "Read options from specified file, do not read the default options file" complete -c $__fish_complete_gpg_command -l no-options -d "Shortcut for '--options /dev/null'" complete -c $__fish_complete_gpg_command -l load-extension -x -d "Load an extension module" complete -c $__fish_complete_gpg_command -l status-fd -x -d "Write special status strings to the specified file descriptor" complete -c $__fish_complete_gpg_command -l logger-fd -x -d "Write log output to the specified file descriptor" complete -c $__fish_complete_gpg_command -l logger-file -r -d "Write log output to the specified file" complete -c $__fish_complete_gpg_command -l attribute-fd -d "Write attribute subpackets to the specified file descriptor" complete -c $__fish_complete_gpg_command -l sk-comments -d "Include secret key comment packets when exporting secret keys" complete -c $__fish_complete_gpg_command -l no-sk-comments -d "Don't include secret key comment packets when exporting secret keys" complete -c $__fish_complete_gpg_command -l comment -x -d "Use specified string as comment string" complete -c $__fish_complete_gpg_command -l no-comments -d "Don't use a comment string" complete -c $__fish_complete_gpg_command -l emit-version -d "Include the version string in ASCII armored output" complete -c $__fish_complete_gpg_command -l no-emit-version -d "Don't include the version string in ASCII armored output" complete -c $__fish_complete_gpg_command -l sig-notation -x complete -c $__fish_complete_gpg_command -l cert-notation -x complete -c $__fish_complete_gpg_command -s N -l set-notation -x -d "Put the specified name value pair into the signature as notation data" complete -c $__fish_complete_gpg_command -l sig-policy-url -x -d "Set signature policy" complete -c $__fish_complete_gpg_command -l cert-policy-url -x -d "Set certificate policy" complete -c $__fish_complete_gpg_command -l set-policy-url -x -d "Set signature and certificate policy" complete -c $__fish_complete_gpg_command -l sig-keyserver-url -x -d "Use specified URL as a preferred keyserver for data signatures" complete -c $__fish_complete_gpg_command -l set-filename -x -d "Use specified string as the filename which is stored inside messages" complete -c $__fish_complete_gpg_command -l for-your-eyes-only -d "Set the 'for your eyes only' flag in the message" complete -c $__fish_complete_gpg_command -l no-for-your-eyes-only -d "Clear the 'for your eyes only' flag in the message" complete -c $__fish_complete_gpg_command -l use-embedded-filename -d "Create file with name as given in data" complete -c $__fish_complete_gpg_command -l no-use-embedded-filename -d "Don't create file with name as given in data" complete -c $__fish_complete_gpg_command -l completes-needed -x -d "Number of completely trusted users to introduce a new key signer (defaults to 1)" complete -c $__fish_complete_gpg_command -l marginals-needed -x -d "Number of marginally trusted users to introduce a new key signer (defaults to 3)" complete -c $__fish_complete_gpg_command -l max-cert-depth -x -d "Maximum depth of a certification chain (default is 5)" complete -c $__fish_complete_gpg_command -l cipher-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Cipher)" -d "Use specified cipher algorithm" complete -c $__fish_complete_gpg_command -l digest-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Hash)" -d "Use specified message digest algorithm" complete -c $__fish_complete_gpg_command -l compress-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Compression)" -d "Use specified compression algorithm" complete -c $__fish_complete_gpg_command -l cert-digest-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Hash)" -d "Use specified message digest algorithm when signing a key" complete -c $__fish_complete_gpg_command -l s2k-cipher-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Cipher)" -d "Use specified cipher algorithm to protect secret keys" complete -c $__fish_complete_gpg_command -l s2k-digest-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Hash)" -d "Use specified digest algorithm to mangle the passphrases" complete -c $__fish_complete_gpg_command -l s2k-mode -xa "0\t'Plain passphrase' 1\t'Salted passphrase' 3\t'Repeated salted mangling'" -d "Selects how passphrases are mangled" complete -c $__fish_complete_gpg_command -l disable-cipher-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Cipher)" -d "Never allow the use of specified cipher algorithm" complete -c $__fish_complete_gpg_command -l disable-pubkey-algo -xa "(__fish_print_gpg_algo $__fish_complete_gpg_command Pubkey)" -d "Never allow the use of specified public key algorithm" complete -c $__fish_complete_gpg_command -l no-sig-cache -d "Do not cache the verification status of key signatures" complete -c $__fish_complete_gpg_command -l auto-check-trustdb -d "Automatically run the --check-trustdb command internally when needed" complete -c $__fish_complete_gpg_command -l no-auto-check-trustdb -d "Never automatically run the --check-trustdb" complete -c $__fish_complete_gpg_command -l throw-keyids -d "Do not put the recipient keyid into encrypted packets" complete -c $__fish_complete_gpg_command -l no-throw-keyids -d "Put the recipient keyid into encrypted packets" complete -c $__fish_complete_gpg_command -l not-dash-escaped -d "Change the behavior of cleartext signatures so that they can be used for patch files" complete -c $__fish_complete_gpg_command -l escape-from-lines -d "Mangle From-field of email headers (default)" complete -c $__fish_complete_gpg_command -l no-escape-from-lines -d "Do not mangle From-field of email headers" complete -c $__fish_complete_gpg_command -l passphrase-fd -x -d "Read passphrase from specified file descriptor" complete -c $__fish_complete_gpg_command -l command-fd -x -d "Read user input from specified file descriptor" complete -c $__fish_complete_gpg_command -l use-agent -d "Try to use the GnuPG-Agent" complete -c $__fish_complete_gpg_command -l no-use-agent -d "Do not try to use the GnuPG-Agent" complete -c $__fish_complete_gpg_command -l gpg-agent-info -x -d "Override value of GPG_AGENT_INFO environment variable" complete -c $__fish_complete_gpg_command -l force-v3-sigs -d "Force v3 signatures for signatures on data" complete -c $__fish_complete_gpg_command -l no-force-v3-sigs -d "Do not force v3 signatures for signatures on data" complete -c $__fish_complete_gpg_command -l force-v4-certs -d "Always use v4 key signatures even on v3 keys" complete -c $__fish_complete_gpg_command -l no-force-v4-certs -d "Don't use v4 key signatures on v3 keys" complete -c $__fish_complete_gpg_command -l force-mdc -d "Force the use of encryption with a modification detection code" complete -c $__fish_complete_gpg_command -l disable-mdc -d "Disable the use of the modification detection code" complete -c $__fish_complete_gpg_command -l allow-non-selfsigned-uid -d "Allow the import and use of keys with user IDs which are not self-signed" complete -c $__fish_complete_gpg_command -l no-allow-non-selfsigned-uid -d "Do not allow the import and use of keys with user IDs which are not self-signed" complete -c $__fish_complete_gpg_command -l allow-freeform-uid -d "Disable all checks on the form of the user ID while generating a new one" complete -c $__fish_complete_gpg_command -l ignore-time-conflict -d "Do not fail if signature is older than key" complete -c $__fish_complete_gpg_command -l ignore-valid-from -d "Allow subkeys that have a timestamp from the future" complete -c $__fish_complete_gpg_command -l ignore-crc-error -d "Ignore CRC errors" complete -c $__fish_complete_gpg_command -l ignore-mdc-error -d "Do not fail on MDC integrity protection failure" complete -c $__fish_complete_gpg_command -l lock-once -d "Lock the databases the first time a lock is requested and do not release the lock until the process terminates" complete -c $__fish_complete_gpg_command -l lock-multiple -d "Release the locks every time a lock is no longer needed" complete -c $__fish_complete_gpg_command -l no-random-seed-file -d "Do not create an internal pool file for quicker generation of random numbers" complete -c $__fish_complete_gpg_command -l no-verbose -d "Reset verbose level to 0" complete -c $__fish_complete_gpg_command -l no-greeting -d "Suppress the initial copyright message" complete -c $__fish_complete_gpg_command -l no-secmem-warning -d "Suppress the warning about 'using insecure memory'" complete -c $__fish_complete_gpg_command -l no-permission-warning -d "Suppress the warning about unsafe file and home directory (--homedir) permissions" complete -c $__fish_complete_gpg_command -l no-mdc-warning -d "Suppress the warning about missing MDC integrity protection" complete -c $__fish_complete_gpg_command -l require-secmem -d "Refuse to run if GnuPG cannot get secure memory" complete -c $__fish_complete_gpg_command -l no-require-secmem -d "Do not refuse to run if GnuPG cannot get secure memory (default)" complete -c $__fish_complete_gpg_command -l no-armor -d "Assume the input data is not in ASCII armored format" complete -c $__fish_complete_gpg_command -l no-default-keyring -d "Do not add the default keyrings to the list of keyrings" complete -c $__fish_complete_gpg_command -l skip-verify -d "Skip the signature verification step" complete -c $__fish_complete_gpg_command -l with-colons -d "Print key listings delimited by colons" complete -c $__fish_complete_gpg_command -l with-key-data -d "Print key listings delimited by colons (like --with-colons) and print the public key data" complete -c $__fish_complete_gpg_command -l with-fingerprint -d "Same as the command --fingerprint but changes only the format of the output and may be used together with another command" complete -c $__fish_complete_gpg_command -l with-subkey-fingerprint -d "Force printing of all subkeys" complete -c $__fish_complete_gpg_command -l fast-list-mode -d "Changes the output of the list commands to work faster" complete -c $__fish_complete_gpg_command -l fixed-list-mode -d "Do not merge primary user ID and primary key in --with-colon listing mode and print all timestamps as UNIX timestamps" complete -c $__fish_complete_gpg_command -l list-only -d "Changes the behaviour of some commands. This is like --dry-run but different" complete -c $__fish_complete_gpg_command -l show-session-key -d "Display the session key used for one message" complete -c $__fish_complete_gpg_command -l ask-sig-expire -d "Prompt for an expiration time" complete -c $__fish_complete_gpg_command -l no-ask-sig-expire -d "Do not prompt for an expiration time" complete -c $__fish_complete_gpg_command -l ask-cert-expire -d "Prompt for an expiration time" complete -c $__fish_complete_gpg_command -l no-ask-cert-expire -d "Do not prompt for an expiration time" complete -c $__fish_complete_gpg_command -l try-all-secrets -d "Don't look at the key ID as stored in the message but try all secret keys in turn to find the right decryption key" complete -c $__fish_complete_gpg_command -l enable-special-filenames -d "Enable a mode in which filenames of the form -&n, where n is a non-negative decimal number, refer to the file descriptor n and not to a file with that name" complete -c $__fish_complete_gpg_command -l group -x -d "Sets up a named group, which is similar to aliases in email programs" complete -c $__fish_complete_gpg_command -l ungroup -d "Remove a given entry from the --group list" complete -c $__fish_complete_gpg_command -l no-groups -d "Remove all entries from the --group list" complete -c $__fish_complete_gpg_command -l preserve-permissions -d "Don't change the permissions of a secret keyring back to user read/write only" complete -c $__fish_complete_gpg_command -l personal-cipher-preferences -x -d "Set the list of personal cipher preferences to the specified string" complete -c $__fish_complete_gpg_command -l personal-digest-preferences -x -d "Set the list of personal digest preferences to the specified string" complete -c $__fish_complete_gpg_command -l personal-compress-preferences -x -d "Set the list of personal compression preferences to the specified string" complete -c $__fish_complete_gpg_command -l default-preference-list -x -d "Set the list of default preferences to the specified string" complete -c $__fish_complete_gpg_command -l openpgp -x -d "Use strict OpenPGP behaviour" end ================================================ FILE: tests/fish_files/__fish_config_interactive.fish ================================================ # # Initializations that should only be performed when entering interactive mode. # # This function is called by the __fish_on_interactive function, which is defined in config.fish. # function __fish_config_interactive -d "Initializations that should be performed when entering interactive mode" # Make sure this function is only run once. if set -q __fish_config_interactive_done return end # For one-off upgrades of the fish version if not set -q __fish_initialized set -U __fish_initialized 0 end set -g __fish_config_interactive_done set -g __fish_active_key_bindings # usage: __init_uvar VARIABLE VALUES... function __init_uvar -d "Sets a universal variable if it's not already set" if not set --query $argv[1] set --universal $argv end end # If we are starting up for the first time, set various defaults. if test $__fish_initialized -lt 3400 # Regular syntax highlighting colors __init_uvar fish_color_normal normal __init_uvar fish_color_command blue __init_uvar fish_color_param cyan __init_uvar fish_color_redirection cyan --bold __init_uvar fish_color_comment red __init_uvar fish_color_error brred __init_uvar fish_color_escape brcyan __init_uvar fish_color_operator brcyan __init_uvar fish_color_end green __init_uvar fish_color_quote yellow __init_uvar fish_color_autosuggestion 555 brblack __init_uvar fish_color_user brgreen __init_uvar fish_color_host normal __init_uvar fish_color_host_remote yellow __init_uvar fish_color_valid_path --underline __init_uvar fish_color_status red __init_uvar fish_color_cwd green __init_uvar fish_color_cwd_root red # Background color for search matches __init_uvar fish_color_search_match --background=111 # Background color for selections __init_uvar fish_color_selection white --bold --background=brblack # XXX fish_color_cancel was added in 2.6, but this was added to post-2.3 initialization # when 2.4 and 2.5 were already released __init_uvar fish_color_cancel -r # Pager colors __init_uvar fish_pager_color_prefix cyan --bold --underline __init_uvar fish_pager_color_completion normal __init_uvar fish_pager_color_description B3A06D yellow -i __init_uvar fish_pager_color_progress brwhite --background=cyan __init_uvar fish_pager_color_selected_background -r # # Directory history colors # __init_uvar fish_color_history_current --bold end # # Generate man page completions if not present. # # Don't do this if we're being invoked as part of running unit tests. if not set -q FISH_UNIT_TESTS_RUNNING # Check if our manpage completion script exists because some distros split it out. # (#7183) set -l script $__fish_data_dir/tools/create_manpage_completions.py if not test -d $__fish_user_data_dir/generated_completions; and test -e "$script" # Generating completions from man pages needs python (see issue #3588). # We cannot simply do `fish_update_completions &` because it is a function. # We cannot do `eval` since it is a function. # We don't want to call `fish -c` since that is unnecessary and sources config.fish again. # Hence we'll call python directly. # c_m_p.py should work with any python version. set -l update_args -B $__fish_data_dir/tools/create_manpage_completions.py --manpath --cleanup-in '~/.config/fish/completions' --cleanup-in '~/.config/fish/generated_completions' if set -l python (__fish_anypython) # Run python directly in the background and swallow all output $python $update_args >/dev/null 2>&1 & # Then disown the job so that it continues to run in case of an early exit (#6269) disown >/dev/null 2>&1 end end end # # Print a greeting. # The default just prints a variable of the same name. # # NOTE: This status check is necessary to not print the greeting when `read`ing in scripts. See #7080. if status --is-interactive and functions -q fish_greeting fish_greeting end # # Completions for SysV startup scripts. These aren't bound to any # specific command, so they can't be autoloaded. # if test -d /etc/init.d complete -x -p "/etc/init.d/*" -a start --description 'Start service' complete -x -p "/etc/init.d/*" -a stop --description 'Stop service' complete -x -p "/etc/init.d/*" -a status --description 'Print service status' complete -x -p "/etc/init.d/*" -a restart --description 'Stop and then start service' complete -x -p "/etc/init.d/*" -a reload --description 'Reload service configuration' end # # We want to show our completions for the [ (test) builtin, but # we don't want to create a [.fish. test.fish will not be loaded until # the user tries [ interactively. # complete -c [ --wraps test complete -c ! --wraps not # # Only a few builtins take filenames; initialize the rest with no file completions # complete -c(builtin -n | string match -rv '(\.|:|source|cd|contains|count|echo|exec|printf|random|realpath|set|\\[|test|for)') --no-files # Reload key bindings when binding variable change function __fish_reload_key_bindings -d "Reload key bindings when binding variable change" --on-variable fish_key_bindings # Make sure some key bindings are set __init_uvar fish_key_bindings fish_default_key_bindings # Do nothing if the key bindings didn't actually change. # This could be because the variable was set to the existing value # or because it was a local variable. # If fish_key_bindings is empty on the first run, we still need to set the defaults. if test "$fish_key_bindings" = "$__fish_active_key_bindings" -a -n "$fish_key_bindings" return end # Check if fish_key_bindings is a valid function. # If not, either keep the previous bindings (if any) or revert to default. # Also print an error so the user knows. if not functions -q "$fish_key_bindings" echo "There is no fish_key_bindings function called: '$fish_key_bindings'" >&2 # We need to see if this is a defined function, otherwise we'd be in an endless loop. if functions -q $__fish_active_key_bindings echo "Keeping $__fish_active_key_bindings" >&2 # Set the variable to the old value so this error doesn't happen again. set fish_key_bindings $__fish_active_key_bindings return 1 else if functions -q fish_default_key_bindings echo "Reverting to default bindings" >&2 set fish_key_bindings fish_default_key_bindings # Return because we are called again return 0 else # If we can't even find the default bindings, something is broken. # Without it, we would eventually run into the stack size limit, but that'd print hundreds of duplicate lines # so we should give up earlier. echo "Cannot find fish_default_key_bindings, falling back to very simple bindings." >&2 echo "Most likely something is wrong with your installation." >&2 return 0 end end set -g __fish_active_key_bindings "$fish_key_bindings" set -g fish_bind_mode default if test "$fish_key_bindings" = fish_default_key_bindings # Redirect stderr per #1155 fish_default_key_bindings 2>/dev/null else $fish_key_bindings 2>/dev/null end # Load user key bindings if they are defined if functions --query fish_user_key_bindings >/dev/null fish_user_key_bindings 2>/dev/null end end # Load key bindings __fish_reload_key_bindings # Enable bracketed paste exception when running unit tests so we don't have to add # the sequences to bind.expect if not set -q FISH_UNIT_TESTS_RUNNING # Enable bracketed paste before every prompt (see __fish_shared_bindings for the bindings). # Enable bracketed paste when the read builtin is used. function __fish_enable_bracketed_paste --on-event fish_prompt --on-event fish_read printf "\e[?2004h" end # Disable BP before every command because that might not support it. function __fish_disable_bracketed_paste --on-event fish_preexec --on-event fish_exit printf "\e[?2004l" end # Tell the terminal we support BP. Since we are in __f_c_i, the first fish_prompt # has already fired. __fish_enable_bracketed_paste end # Similarly, enable TMUX's focus reporting when in tmux. # This will be handled by # - The keybindings (reading the sequence and triggering an event) # - Any listeners (like the vi-cursor) if set -q TMUX and not set -q FISH_UNIT_TESTS_RUNNING function __fish_enable_focus --on-event fish_postexec echo -n \e\[\?1004h end function __fish_disable_focus --on-event fish_preexec echo -n \e\[\?1004l end # Note: Don't call this initially because, even though we're in a fish_prompt event, # tmux reacts sooo quickly that we'll still get a sequence before we're prepared for it. # So this means that we won't get focus events until you've run at least one command, but that's preferable # to always seeing `^[[I` when starting fish. # __fish_enable_focus end # Detect whether the terminal reflows on its own # If it does we shouldn't do it. # Allow $fish_handle_reflow to override it. if not set -q fish_handle_reflow # VTE reflows the text itself, so us doing it inevitably races against it. # Guidance from the VTE developers is to let them repaint. if set -q VTE_VERSION # Same for alacritty or string match -q -- 'alacritty*' $TERM # Same for kitty or string match -q -- '*kitty' $TERM set -g fish_handle_reflow 0 else if set -q KONSOLE_VERSION and test "$KONSOLE_VERSION" -ge 210400 2>/dev/null # Konsole since version 21.04(.00) # Note that this is optional, but since we have no way of detecting it # we go with the default, which is true. set -g fish_handle_reflow 0 else set -g fish_handle_reflow 1 end end function __fish_winch_handler --on-signal WINCH -d "Repaint screen when window changes size" if test "$fish_handle_reflow" = 1 2>/dev/null commandline -f repaint >/dev/null 2>/dev/null end end # Notify terminals when $PWD changes (issue #906). # VTE based terminals, Terminal.app, iTerm.app (TODO), foot, and kitty support this. if not set -q FISH_UNIT_TESTS_RUNNING and begin string match -q -- 'foot*' $TERM or string match -q -- 'xterm-kitty*' $TERM or test 0"$VTE_VERSION" -ge 3405 or test "$TERM_PROGRAM" = Apple_Terminal && test (string match -r '\d+' 0"$TERM_PROGRAM_VERSION") -ge 309 or test "$TERM_PROGRAM" = WezTerm end function __update_cwd_osc --on-variable PWD --description 'Notify capable terminals when $PWD changes' if status --is-command-substitution || set -q INSIDE_EMACS return end printf \e\]7\;file://%s%s\a $hostname (string escape --style=url $PWD) end __update_cwd_osc # Run once because we might have already inherited a PWD from an old tab end # Create empty configuration of directories if they do not already exist test -e $__fish_config_dir/completions/ -a -e $__fish_config_dir/conf.d/ -a -e $__fish_config_dir/functions/ || mkdir -p $__fish_config_dir/{completions, conf.d, functions} # Create config.fish with some boilerplate if it does not exist test -e $__fish_config_dir/config.fish || echo "\ if status is-interactive # Commands to run in interactive sessions can go here end" >$__fish_config_dir/config.fish # Bump this whenever some code below needs to run once when upgrading to a new version. # The universal variable __fish_initialized is initialized in share/config.fish. set __fish_initialized 3400 end ================================================ FILE: tests/fish_files/__fish_shared_key_bindings.fish ================================================ function __fish_shared_key_bindings -d "Bindings shared between emacs and vi mode" # These are some bindings that are supposed to be shared between vi mode and default mode. # They are supposed to be unrelated to text-editing (or movement). # This takes $argv so the vi-bindings can pass the mode they are valid in. if contains -- -h $argv or contains -- --help $argv echo "Sorry but this function doesn't support -h or --help" >&2 return 1 end bind --preset $argv \cy yank or return # protect against invalid $argv bind --preset $argv \ey yank-pop # Left/Right arrow bind --preset $argv -k right forward-char bind --preset $argv -k left backward-char bind --preset $argv \e\[C forward-char bind --preset $argv \e\[D backward-char # Some terminals output these when they're in in keypad mode. bind --preset $argv \eOC forward-char bind --preset $argv \eOD backward-char # Ctrl-left/right - these also work in vim. bind --preset $argv \e\[1\;5C forward-word bind --preset $argv \e\[1\;5D backward-word bind --preset $argv -k ppage beginning-of-history bind --preset $argv -k npage end-of-history # Interaction with the system clipboard. bind --preset $argv \cx fish_clipboard_copy bind --preset $argv \cv fish_clipboard_paste bind --preset $argv \e cancel bind --preset $argv \t complete bind --preset $argv \cs pager-toggle-search # shift-tab does a tab complete followed by a search. bind --preset $argv --key btab complete-and-search bind --preset $argv \e\n "commandline -f expand-abbr; commandline -i \n" bind --preset $argv \e\r "commandline -f expand-abbr; commandline -i \n" bind --preset $argv -k down down-or-search bind --preset $argv -k up up-or-search bind --preset $argv \e\[A up-or-search bind --preset $argv \e\[B down-or-search bind --preset $argv \eOA up-or-search bind --preset $argv \eOB down-or-search bind --preset $argv -k sright forward-bigword bind --preset $argv -k sleft backward-bigword # Alt-left/Alt-right bind --preset $argv \e\eOC nextd-or-forward-word bind --preset $argv \e\eOD prevd-or-backward-word bind --preset $argv \e\e\[C nextd-or-forward-word bind --preset $argv \e\e\[D prevd-or-backward-word bind --preset $argv \eO3C nextd-or-forward-word bind --preset $argv \eO3D prevd-or-backward-word bind --preset $argv \e\[3C nextd-or-forward-word bind --preset $argv \e\[3D prevd-or-backward-word bind --preset $argv \e\[1\;3C nextd-or-forward-word bind --preset $argv \e\[1\;3D prevd-or-backward-word bind --preset $argv \e\[1\;9C nextd-or-forward-word #iTerm2 bind --preset $argv \e\[1\;9D prevd-or-backward-word #iTerm2 # Alt-up/Alt-down bind --preset $argv \e\eOA history-token-search-backward bind --preset $argv \e\eOB history-token-search-forward bind --preset $argv \e\e\[A history-token-search-backward bind --preset $argv \e\e\[B history-token-search-forward bind --preset $argv \eO3A history-token-search-backward bind --preset $argv \eO3B history-token-search-forward bind --preset $argv \e\[3A history-token-search-backward bind --preset $argv \e\[3B history-token-search-forward bind --preset $argv \e\[1\;3A history-token-search-backward bind --preset $argv \e\[1\;3B history-token-search-forward bind --preset $argv \e\[1\;9A history-token-search-backward # iTerm2 bind --preset $argv \e\[1\;9B history-token-search-forward # iTerm2 # Bash compatibility # https://github.com/fish-shell/fish-shell/issues/89 bind --preset $argv \e. history-token-search-backward bind --preset $argv \el __fish_list_current_token bind --preset $argv \eo __fish_preview_current_file bind --preset $argv \ew __fish_whatis_current_token # ncurses > 6.0 sends a "delete scrollback" sequence along with clear. # This string replace removes it. bind --preset $argv \cl 'echo -n (clear | string replace \e\[3J ""); commandline -f repaint' bind --preset $argv \cc cancel-commandline bind --preset $argv \cu backward-kill-line bind --preset $argv \cw backward-kill-path-component bind --preset $argv \e\[F end-of-line bind --preset $argv \e\[H beginning-of-line bind --preset $argv \ed 'set -l cmd (commandline); if test -z "$cmd"; echo; dirh; commandline -f repaint; else; commandline -f kill-word; end' bind --preset $argv \cd delete-or-exit bind --preset $argv \es "if command -q sudo; fish_commandline_prepend sudo; else if command -q doas; fish_commandline_prepend doas; end" # Allow reading manpages by pressing F1 (many GUI applications) or Alt+h (like in zsh). bind --preset $argv -k f1 __fish_man_page bind --preset $argv \eh __fish_man_page # This will make sure the output of the current command is paged using the default pager when # you press Meta-p. # If none is set, less will be used. bind --preset $argv \ep __fish_paginate # Make it easy to turn an unexecuted command into a comment in the shell history. Also, # remove the commenting chars so the command can be further edited then executed. bind --preset $argv \e\# __fish_toggle_comment_commandline # The [meta-e] and [meta-v] keystrokes invoke an external editor on the command buffer. bind --preset $argv \ee edit_command_buffer bind --preset $argv \ev edit_command_buffer # Tmux' focus events. # Exclude paste mode because that should get _everything_ literally. for mode in (bind --list-modes | string match -v paste) # We only need the in-focus event currently (to redraw the vi-cursor). bind --preset -M $mode \e\[I 'emit fish_focus_in' bind --preset -M $mode \e\[O false bind --preset -M $mode \e\[\?1004h false end # Support for "bracketed paste" # The way it works is that we acknowledge our support by printing # \e\[?2004h # then the terminal will "bracket" every paste in # \e\[200~ and \e\[201~ # Every character in between those two will be part of the paste and should not cause a binding to execute (like \n executing commands). # # We enable it after every command and disable it before (in __fish_config_interactive.fish) # # Support for this seems to be ubiquitous - emacs enables it unconditionally (!) since 25.1 # (though it only supports it since then, it seems to be the last term to gain support). # # NOTE: This is more of a "security" measure than a proper feature. # The better way to paste remains the `fish_clipboard_paste` function (bound to \cv by default). # We don't disable highlighting here, so it will be redone after every character (which can be slow), # and it doesn't handle "paste-stop" sequences in the paste (which the terminal needs to strip). # # See http://thejh.net/misc/website-terminal-copy-paste. # Bind the starting sequence in every bind mode, even user-defined ones. # Exclude paste mode or there'll be an additional binding after switching between emacs and vi for mode in (bind --list-modes | string match -v paste) bind --preset -M $mode -m paste \e\[200~ __fish_start_bracketed_paste end # This sequence ends paste-mode and returns to the previous mode we have saved before. bind --preset -M paste \e\[201~ __fish_stop_bracketed_paste # In paste-mode, everything self-inserts except for the sequence to get out of it bind --preset -M paste "" self-insert # Without this, a \r will overwrite the other text, rendering it invisible - which makes the exercise kinda pointless. bind --preset -M paste \r "commandline -i \n" # We usually just pass the text through as-is to facilitate pasting code, # but when the current token contains an unbalanced single-quote (`'`), # we escape all single-quotes and backslashes, effectively turning the paste # into one literal token, to facilitate pasting non-code (e.g. markdown or git commitishes) bind --preset -M paste "'" "__fish_commandline_insert_escaped \' \$__fish_paste_quoted" bind --preset -M paste \\ "__fish_commandline_insert_escaped \\\ \$__fish_paste_quoted" # Only insert spaces if we're either quoted or not at the beginning of the commandline # - this strips leading spaces if they would trigger histignore. bind --preset -M paste " " self-insert-notfirst # Bindings that are shared in text-insertion modes. if not set -l index (contains --index -- -M $argv) or test $argv[(math $index + 1)] = insert # This is the default binding, i.e. the one used if no other binding matches bind --preset $argv "" self-insert or exit # protect against invalid $argv # Space and other command terminators expands abbrs _and_ inserts itself. bind --preset $argv " " self-insert expand-abbr bind --preset $argv ";" self-insert expand-abbr bind --preset $argv "|" self-insert expand-abbr bind --preset $argv "&" self-insert expand-abbr bind --preset $argv "^" self-insert expand-abbr bind --preset $argv ">" self-insert expand-abbr bind --preset $argv "<" self-insert expand-abbr # Closing a command substitution expands abbreviations bind --preset $argv ")" self-insert expand-abbr # Ctrl-space inserts space without expanding abbrs bind --preset $argv -k nul 'test -n "$(commandline)" && commandline -i " "' # Shift-space (CSI u escape sequence) behaves like space because it's easy to mistype. bind --preset $argv \e\[32\;2u 'commandline -i " "; commandline -f expand-abbr' bind --preset $argv \n execute bind --preset $argv \r execute # Control+Return behave like Return because it's easy to mistype after accepting an autosuggestion. bind --preset $argv \e\[27\;5\;13~ execute # Sent with XTerm.vt100.formatOtherKeys: 0 bind --preset $argv \e\[13\;5u execute # CSI u sequence, sent with XTerm.vt100.formatOtherKeys: 1 end end function __fish_commandline_insert_escaped --description 'Insert the first arg escaped if a second arg is given' if set -q argv[2] commandline -i \\$argv[1] else commandline -i $argv[1] end end function __fish_start_bracketed_paste # Save the last bind mode so we can restore it. set -g __fish_last_bind_mode $fish_bind_mode # If the token is currently single-quoted, # we escape single-quotes (and backslashes). string match -q 'single*' (__fish_tokenizer_state -- (commandline -ct | string collect)) and set -g __fish_paste_quoted 1 end function __fish_stop_bracketed_paste # Restore the last bind mode. set fish_bind_mode $__fish_last_bind_mode set -e __fish_paste_quoted end ================================================ FILE: tests/fish_files/advanced/better_variable_scopes.fish ================================================ #!/usr/bin/env fish ### File was take from the following fish shell excerpt: `man fish-language /Variable Scope` ### ### ### Variable Scope ### There are four kinds of variables in fish: universal, global, function and local variables. ### ### • Universal variables are shared between all fish sessions a user is running on one computer. They are stored on disk and persist even after reboot. ### ### • Global variables are specific to the current fish session. They can be erased by explicitly requesting set -e. ### ### • Function variables are specific to the currently executing function. They are erased ("go out of scope") when the current function ends. Outside of a function, they don't go out of scope. ### ### • Local variables are specific to the current block of commands, and automatically erased when a specific block goes out of scope. A block of commands is a series of commands that begins with one of ### the commands for, while , if, function, begin or switch, and ends with the command end. Outside of a block, this is the same as the function scope. ### ### Variables can be explicitly set to be universal with the -U or --universal switch, global with -g or --global, function-scoped with -f or --function and local to the current block with -l or --local. ### The scoping rules when creating or updating a variable are: ### ### • When a scope is explicitly given, it will be used. If a variable of the same name exists in a different scope, that variable will not be changed. ### ### • When no scope is given, but a variable of that name exists, the variable of the smallest scope will be modified. The scope will not be changed. ### ### • When no scope is given and no variable of that name exists, the variable is created in function scope if inside a function, or global scope if no function is executing. ### ### There can be many variables with the same name, but different scopes. When you use a variable, the smallest scoped variable of that name will be used. If a local variable exists, it will be used in‐ ### stead of the global or universal variable of the same name. ### ### Example: function test-scopes begin # This is a nice local scope where all variables will die set -l pirate 'There be treasure in them hills' set -f captain Space, the final frontier # If no variable of that name was defined, it is function-local. set gnu "In the beginning there was nothing, which exploded" end echo $pirate # This will not output anything, since the pirate was local echo $captain # This will output the good Captain's speech since $captain had function-scope. echo $gnu # Will output Sir Terry's wisdom. end test-scopes # When a function calls another, local variables aren't visible: function shiver set phrase 'Shiver me timbers' end function avast set --local phrase 'Avast, mateys' # Calling the shiver function here can not # change any variables in the local scope # so phrase remains as we set it here. shiver echo $phrase end avast # Outputs "Avast, mateys" ================================================ FILE: tests/fish_files/advanced/inner_functions.fish ================================================ # PROGRAM function func_a --argument-names arg_1 arg_2 set --local args "$argv" function func_b set --local args "$argv 1" set --local args "$args 2" set --local args "$args 3" end function func_c set --local args "$argv" end func_b $args func_b $arg_1 func_c $arg_2 set --local args "$argv[2]" set arg $argv[1] for arg in $argv[-2..-1] echo $arg end for arg in $argv[-3..-1] echo $arg end set args "$argv[2]" end function func_outside --argument-names arg_1 arg_2 echo $argv end func_a 1 2 func_outside 1 2 set args 'a b c' ================================================ FILE: tests/fish_files/advanced/lots_of_globals.fish ================================================ # lots_of_globals -- creates 4 global variables function lots_of_globals --description "Lots of globals" set -gx a 1 set -gx b 2 set -gx c 3 set -gx d 4 end set --global abcd 1 2 3 4 set --local ghik 5 6 7 8 set --universal mnop 9 10 11 12 set zxcv 13 14 15 16 __lots_of_globals_helper function __lots_of_globals_helper set --global PATH '/usr/local/bin' '/usr/bin' '/bin' '/usr/sbin' '/sbin' end ================================================ FILE: tests/fish_files/advanced/multiple_functions.fish ================================================ # preceding chars function multiple_functions --argument-names file1 file2 file3 echo "file1 is $file1" echo "file2 is $file2" echo "file3 is $file3" end function other_functions for i in $argv echo "file$i is $i" end for i in $argv echo "file$i is $i" end end set --local files 'file1' 'file2' 'file3' other_functions "$files" set --universal files 'not' ================================================ FILE: tests/fish_files/advanced/variable_scope.fish ================================================ #!/usr/local/bin/fish # file to show how scope works in fish shell # notice that the variable i is still available after the for loop # and that the variable ii is not available after the if statement for i in (seq 1 10) echo "." end echo $i if true set ii 20 else set ii -1 end echo $ii function aaa set v "hi" function bbb set v "hello" end echo $v bbb end aaa begin; set ii 30 end; echo $ii ================================================ FILE: tests/fish_files/advanced/variable_scope_2.fish ================================================ #!/usr/bin/env fish ### File was take from the following fish shell excerpt: ### Variable Scope ## There are four kinds of variables in fish: universal, global, function and local variables. ## ## • Universal variables are shared between all fish sessions a user is running on one computer. They are stored on disk and persist even after reboot. ## ## • Global variables are specific to the current fish session. They can be erased by explicitly requesting set -e. ## ## • Function variables are specific to the currently executing function. They are erased ("go out of scope") when the current function ends. Outside of a function, they don't go out of scope. ## ## • Local variables are specific to the current block of commands, and automatically erased when a specific block goes out of scope. A block of commands is a series of commands that begins with one of ## the commands for, while , if, function, begin or switch, and ends with the command end. Outside of a block, this is the same as the function scope. ## ## Variables can be explicitly set to be universal with the -U or --universal switch, global with -g or --global, function-scoped with -f or --function and local to the current block with -l or --local. ## The scoping rules when creating or updating a variable are: ## ## • When a scope is explicitly given, it will be used. If a variable of the same name exists in a different scope, that variable will not be changed. ## ## • When no scope is given, but a variable of that name exists, the variable of the smallest scope will be modified. The scope will not be changed. ## ## • When no scope is given and no variable of that name exists, the variable is created in function scope if inside a function, or global scope if no function is executing. ## ## There can be many variables with the same name, but different scopes. When you use a variable, the smallest scoped variable of that name will be used. If a local variable exists, it will be used in‐ ## stead of the global or universal variable of the same name. ## ## Example: function test-scopes begin # This is a nice local scope where all variables will die set -l pirate 'There be treasure in them hills' set -f captain Space, the final frontier # If no variable of that name was defined, it is function-local. set gnu "In the beginning there was nothing, which exploded" end echo $pirate # This will not output anything, since the pirate was local echo $captain # This will output the good Captain's speech since $captain had function-scope. echo $gnu # Will output Sir Terry's wisdom. end test-scopes # When a function calls another, local variables aren't visible: function shiver set phrase 'Shiver me timbers' end function avast set --local phrase 'Avast, mateys' # Calling the shiver function here can not # change any variables in the local scope # so phrase remains as we set it here. shiver echo $phrase end avast # Outputs "Avast, mateys" ================================================ FILE: tests/fish_files/errors/extra_end.fish ================================================ function func end end ================================================ FILE: tests/fish_files/errors/invalid_pipes.fish ================================================ #todo ================================================ FILE: tests/fish_files/errors/missing_end.fish ================================================ function func ================================================ FILE: tests/fish_files/errors/variable_expansion_missing_name.fish ================================================ echo $ ================================================ FILE: tests/fish_files/fish_config.fish ================================================ function fish_config --description "Launch fish's web based configuration" argparse h/help -- $argv or return if set -q _flag_help __fish_print_help fish_config return 0 end set -l cmd $argv[1] set -e argv[1] set -q cmd[1] or set cmd browse # The web-based configuration UI # Also opened with just `fish_config` or `fish_config browse`. if contains -- $cmd browse set -lx __fish_bin_dir $__fish_bin_dir if set -l python (__fish_anypython) $python "$__fish_data_dir/tools/web_config/webconfig.py" $argv else echo (set_color $fish_color_error)Cannot launch the web configuration tool:(set_color normal) echo (set_color -o)"fish_config browse"(set_color normal) requires Python. echo Installing python will fix this, and also enable completions to be echo automatically generated from man pages.\n echo To change your prompt, use (set_color -o)"fish_config prompt"(set_color normal) or create a (set_color -o)"fish_prompt"(set_color normal) function. echo To list the samples use (set_color -o)"fish_config prompt show"(set_color normal).\n echo You can tweak your colors by setting the (set_color $fish_color_search_match)\$fish_color_\*(set_color normal) variables. end return 0 end if not contains -- $cmd prompt theme echo No such subcommand: $cmd >&2 return 1 end switch $cmd case prompt # prompt - for prompt switching set -l cmd $argv[1] set -e argv[1] if contains -- $cmd list; and set -q argv[1] echo "Too many arguments" >&2 return 1 end set -l prompt_dir $__fish_data_dir/sample_prompts $__fish_data_dir/tools/web_config/sample_prompts switch $cmd case show set -l fish (status fish-path) set -l prompts $prompt_dir/$argv.fish set -q prompts[1]; or set prompts $prompt_dir/*.fish for p in $prompts if not test -e "$p" continue end set -l promptname (string replace -r '.*/([^/]*).fish$' '$1' $p) echo -s (set_color --underline) $promptname (set_color normal) $fish -c 'functions -e fish_right_prompt; source $argv[1]; false fish_prompt echo (set_color normal) if functions -q fish_right_prompt; echo right prompt: (false; fish_right_prompt) end' $p echo end case list '' string replace -r '.*/([^/]*).fish$' '$1' $prompt_dir/*.fish return case choose if set -q argv[2] echo "Too many arguments" >&2 return 1 end if not set -q argv[1] echo "Too few arguments" >&2 return 1 end set -l have for f in $prompt_dir/$argv[1].fish if test -f $f source $f set have $f break end end if not set -q have[1] echo "No such prompt: '$argv[1]'" >&2 return 1 end # Erase the right prompt if it didn't have any. if functions -q fish_right_prompt; and test (functions --details fish_right_prompt) != $have[1] functions --erase fish_right_prompt end case save read -P"Overwrite prompt? [y/N]" -l yesno if string match -riq 'y(es)?' -- $yesno echo Overwriting cp $__fish_config_dir/functions/fish_prompt.fish{,.bak} set -l have if set -q argv[1] for f in $prompt_dir/$argv[1].fish if test -f $f set have $f source $f or return 2 end end if not set -q have[1] echo "No such prompt: '$argv[1]'" >&2 return 1 end end funcsave fish_prompt or return funcsave fish_right_prompt 2>/dev/null return else echo Not overwriting return 1 end end return 0 case theme # Selecting themes set -l cmd $argv[1] set -e argv[1] if contains -- $cmd list; and set -q argv[1] echo "Too many arguments" >&2 return 1 end set -l dir $__fish_config_dir/themes $__fish_data_dir/tools/web_config/themes switch $cmd case list '' string replace -r '.*/([^/]*).theme$' '$1' $dir/*.theme return case demo echo -ns (set_color $fish_color_command || set_color $fish_color_normal) /bright/vixens echo -ns (set_color normal) ' ' echo -ns (set_color $fish_color_param || set_color $fish_color_normal) jump echo -ns (set_color normal) ' ' echo -ns (set_color $fish_color_redirection || set_color $fish_color_normal) '|' echo -ns (set_color normal) ' ' echo -ns (set_color $fish_color_quote || set_color $fish_color_normal) '"fowl"' echo -ns (set_color normal) ' ' echo -ns (set_color $fish_color_redirection || set_color $fish_color_normal) '> quack' echo -ns (set_color normal) ' ' echo -ns (set_color $fish_color_end || set_color $fish_color_normal) '&' set_color normal echo -s (set_color $fish_color_comment || set_color $fish_color_normal) ' # This is a comment' set_color normal echo -ns (set_color $fish_color_command || set_color $fish_color_normal) echo echo -ns (set_color normal) ' ' echo -s (set_color $fish_color_error || set_color $fish_color_normal) "'" (set_color $fish_color_quote || set_color $fish_color_normal) "Errors are the portal to discovery" set_color normal echo -ns (set_color $fish_color_command || set_color $fish_color_normal) Th set_color normal set_color $fish_color_autosuggestion || set_color $fish_color_normal echo is is an autosuggestion echo case show set -l fish (status fish-path) set -l themes $dir/$argv.theme set -q themes[1]; or set themes $dir/*.theme set -l used_themes echo -s (set_color normal; set_color --underline) Current (set_color normal) fish_config theme demo for t in $themes not test -e "$t" and continue set -l themename (string replace -r '.*/([^/]*).theme$' '$1' $t) contains -- $themename $used_themes and continue set -a used_themes $themename echo -s (set_color normal; set_color --underline) $themename (set_color normal) # Use a new, --no-config, fish to display the theme. # So we can use this function, explicitly source it before anything else! functions fish_config | $fish -C "source -" --no-config -c ' fish_config theme choose $argv fish_config theme demo $argv ' $themename end case choose save if set -q argv[2] echo "Too many arguments" >&2 return 1 end if not set -q argv[1] echo "Too few arguments" >&2 return 1 end set -l files $dir/$argv[1].theme set -l file set -l scope -g if contains -- $cmd save read -P"Overwrite theme? [y/N]" -l yesno if not string match -riq 'y(es)?' -- $yesno echo Not overwriting >&2 return 1 end set scope -U end for f in $files if test -e "$f" set file $f break end end if not set -q file[1] echo "No such theme: $argv[1]" >&2 echo "Dirs: $dir" >&2 return 1 end set -l known_colors fish_color_{normal,command,keyword,quote,redirection,\ end,error,param,option,comment,selection,operator,escape,autosuggestion,\ cwd,user,host,host_remote,cancel,search_match} \ fish_pager_color_{progress,background,prefix,completion,description,\ selected_background,selected_prefix,selected_completion,selected_description,\ secondary_background,secondary_prefix,secondary_completion,secondary_description} set -l have_colors while read -lat toks # We only allow color variables. # Not the specific list, but something named *like* a color variable. # # This also takes care of empty lines and comment lines. string match -rq '^fish_(?:pager_)?color.*$' -- $toks[1] or continue # If we're supposed to set universally, remove any shadowing globals, # so the change takes effect immediately (and there's no warning). if test x"$scope" = x-U; and set -qg $toks[1] set -eg $toks[1] end set $scope $toks set -a have_colors $toks[1] end <$file # Set all colors that aren't mentioned to empty for c in $known_colors contains -- $c $have_colors and continue set $scope $c end # Return true if we changed at least one color set -q have_colors[1] return case dump # Write the current theme in .theme format, to stdout. set -L | string match -r '^fish_(?:pager_)?color.*$' case '*' echo "No such command: $cmd" >&2 return 1 end end end ================================================ FILE: tests/fish_files/fish_git_prompt.fish ================================================ # based off of the git-prompt script that ships with git # hence licensed under GPL version 2 (like the rest of fish). # # Written by Lily Ballard and updated by Brian Gernhardt and fish contributors # # This is based on git's git-prompt.bash script, Copyright (C) 2006,2007 Shawn O. Pearce . # The act of porting the code, along with any new code, are Copyright (C) 2012 Lily Ballard. function __fish_git_prompt_show_upstream --description "Helper function for fish_git_prompt" set -l show_upstream $__fish_git_prompt_showupstream set -l svn_prefix # For better SVN upstream information set -l informative set -l svn_url_pattern set -l count set -l upstream git set -l verbose set -l name # Default to informative if __fish_git_prompt_show_informative_status is set if set -q __fish_git_prompt_show_informative_status set informative 1 end set -l svn_remote # get some config options from git-config command git config -z --get-regexp '^(svn-remote\..*\.url|bash\.showupstream)$' 2>/dev/null | while read -lz key value switch $key case bash.showupstream set show_upstream $value test -n "$show_upstream" or return case svn-remote.'*'.url set svn_remote $svn_remote $value # Avoid adding \| to the beginning to avoid needing #?? later if test -n "$svn_url_pattern" set svn_url_pattern $svn_url_pattern"|$value" else set svn_url_pattern $value end set upstream svn+git # default upstream is SVN if available, else git # Save the config key (without .url) for later use set -l remote_prefix (string replace -r '\.url$' '' -- $key) set svn_prefix $svn_prefix $remote_prefix end end # parse configuration variables # and clear informative default when needed for option in $show_upstream switch $option case git svn set upstream $option set -e informative case verbose set verbose 1 set -e informative case informative set informative 1 case name set name 1 case none return end end # Find our upstream switch $upstream case git set upstream '@{upstream}' case svn\* # get the upstream from the 'git-svn-id: …' in a commit message # (git-svn uses essentially the same procedure internally) set -l svn_upstream (git log --first-parent -1 --grep="^git-svn-id: \($svn_url_pattern\)" 2>/dev/null) if test (count $svn_upstream) -ne 0 echo $svn_upstream[-1] | read -l __ svn_upstream __ set svn_upstream (string replace -r '@.*' '' -- $svn_upstream) set -l cur_prefix for i in (seq (count $svn_remote)) set -l remote $svn_remote[$i] set -l mod_upstream (string replace "$remote" "" -- $svn_upstream) if test "$svn_upstream" != "$mod_upstream" # we found a valid remote set svn_upstream $mod_upstream set cur_prefix $svn_prefix[$i] break end end if test -z "$svn_upstream" # default branch name for checkouts with no layout: if test -n "$GIT_SVN_ID" set upstream $GIT_SVN_ID else set upstream git-svn end else set upstream (string replace '/branches' '' -- $svn_upstream | string replace -a '/' '') # Use fetch config to fix upstream set -l fetch_val (command git config "$cur_prefix".fetch) if test -n "$fetch_val" string split -m1 : -- "$fetch_val" | read -l trunk pattern set upstream (string replace -r -- "/$trunk\$" '' $pattern) /$upstream end end else if test $upstream = svn+git set upstream '@{upstream}' end end # Find how many commits we are ahead/behind our upstream set count (command git rev-list --count --left-right $upstream...HEAD 2>/dev/null | string replace \t " ") # calculate the result if test -n "$verbose" # Verbose has a space by default set -l prefix "$___fish_git_prompt_char_upstream_prefix" # Using two underscore version to check if user explicitly set to nothing if not set -q __fish_git_prompt_char_upstream_prefix set prefix " " end echo $count | read -l behind ahead switch "$count" case '' # no upstream case "0 0" # equal to upstream echo "$prefix$___fish_git_prompt_char_upstream_equal" case "0 *" # ahead of upstream echo "$prefix$___fish_git_prompt_char_upstream_ahead$ahead" case "* 0" # behind upstream echo "$prefix$___fish_git_prompt_char_upstream_behind$behind" case '*' # diverged from upstream echo "$prefix$___fish_git_prompt_char_upstream_diverged$ahead-$behind" end if test -n "$count" -a -n "$name" echo " "(command git rev-parse --abbrev-ref "$upstream" 2>/dev/null) end else if test -n "$informative" echo $count | read -l behind ahead switch "$count" case '' # no upstream case "0 0" # equal to upstream case "0 *" # ahead of upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_ahead$ahead" case "* 0" # behind upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_behind$behind" case '*' # diverged from upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_ahead$ahead$___fish_git_prompt_char_upstream_behind$behind" end else switch "$count" case '' # no upstream case "0 0" # equal to upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_equal" case "0 *" # ahead of upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_ahead" case "* 0" # behind upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_behind" case '*' # diverged from upstream echo "$___fish_git_prompt_char_upstream_prefix$___fish_git_prompt_char_upstream_diverged" end end # For the return status test "$count" = "0 0" end function fish_git_prompt --description "Prompt function for Git" # If git isn't installed, there's nothing we can do # Return 1 so the calling prompt can deal with it if not command -sq git return 1 end set -l repo_info (command git rev-parse --git-dir --is-inside-git-dir --is-bare-repository --is-inside-work-tree HEAD 2>/dev/null) test -n "$repo_info" or return set -l git_dir $repo_info[1] set -l inside_gitdir $repo_info[2] set -l bare_repo $repo_info[3] set -l inside_worktree $repo_info[4] set -q repo_info[5] and set -l sha $repo_info[5] set -l rbc (__fish_git_prompt_operation_branch_bare $repo_info) set -l r $rbc[1] # current operation set -l b $rbc[2] # current branch set -l detached $rbc[3] set -l dirtystate #dirty working directory set -l stagedstate #staged changes set -l invalidstate #staged changes set -l stashstate #stashes set -l untrackedfiles #untracked set -l c $rbc[4] # bare repository set -l p #upstream set -l informative_status set -q __fish_git_prompt_status_order or set -g __fish_git_prompt_status_order stagedstate invalidstate dirtystate untrackedfiles stashstate if not set -q ___fish_git_prompt_init # This takes a while, so it only needs to be done once, # and then whenever the configuration changes. __fish_git_prompt_validate_chars __fish_git_prompt_validate_colors set -g ___fish_git_prompt_init end set -l space "$___fish_git_prompt_color$___fish_git_prompt_char_stateseparator$___fish_git_prompt_color_done" # Use our variables as defaults, but allow overrides via the local git config. # That means if neither is set, this stays empty. # # So "!= true" or "!= false" are useful tests if you want to do something by default. set -l informative set -l dirty set -l untracked command git config -z --get-regexp 'bash\.(showInformativeStatus|showDirtyState|showUntrackedFiles)' 2>/dev/null | while read -lz key value switch $key case bash.showinformativestatus set informative $value case bash.showdirtystate set dirty $value case bash.showuntrackedfiles set untracked $value end end # If we don't print these, there is no need to compute them. Note: For now, staged and dirty are coupled. if not set -q dirty[1] && set -q __fish_git_prompt_showdirtystate set dirty true end contains dirtystate $__fish_git_prompt_status_order || contains stagedstate $__fish_git_prompt_status_order or set dirty false if not set -q untracked[1] && set -q __fish_git_prompt_showuntrackedfiles set untracked true end contains untrackedfiles $__fish_git_prompt_status_order or set untracked false if test true = $inside_worktree # Use informative status if it has been enabled locally, or it has been # enabled globally (via the fish variable) and dirty or untracked are not false. # # This is to allow overrides for the repository. if test "$informative" = true or begin set -q __fish_git_prompt_show_informative_status and test "$dirty" != false end set informative_status (untracked=$untracked __fish_git_prompt_informative_status $git_dir) if test -n "$informative_status" set informative_status "$space$informative_status" end else if not test "$dirty" = true; and test "$untracked" = true # Only untracked, ls-files is faster. command git -c core.fsmonitor= ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- :/ >/dev/null 2>&1 and set untrackedfiles 1 else if test "$dirty" = true # With both dirty and untracked, git status is ~10% faster. # With just dirty, it's ~20%. set -l opt -uno test "$untracked" = true; and set opt -unormal # Don't use `--ignored=no`; it was introduced in Git 2.16, from January 2018 # Ignored files are omitted by default set -l stat (command git -c core.fsmonitor= status --porcelain -z $opt | string split0) set dirtystate (string match -qr '^.[ACDMR]' -- $stat; and echo 1) if test -n "$sha" set stagedstate (string match -qr '^[ACDMR].' -- $stat; and echo 1) else set invalidstate 1 end test "$untracked" = true and set untrackedfiles (string match -qr '\?\?' -- $stat; and echo 1) end if set -q __fish_git_prompt_showstashstate and test -r $git_dir/logs/refs/stash set stashstate 1 end end if set -q __fish_git_prompt_showupstream or set -q __fish_git_prompt_show_informative_status set p (__fish_git_prompt_show_upstream) end end set -l branch_color $___fish_git_prompt_color_branch set -l branch_done $___fish_git_prompt_color_branch_done if set -q __fish_git_prompt_showcolorhints if test $detached = yes set branch_color $___fish_git_prompt_color_branch_detached set branch_done $___fish_git_prompt_color_branch_detached_done else if test -n "$dirtystate$untrackedfiles"; and set -q __fish_git_prompt_color_branch_dirty set branch_color (set_color $__fish_git_prompt_color_branch_dirty) set branch_done (set_color $__fish_git_prompt_color_branch_dirty_done) else if test -n "$stagedstate"; and set -q __fish_git_prompt_color_branch_staged set branch_color (set_color $__fish_git_prompt_color_branch_staged) set branch_done (set_color $__fish_git_prompt_color_branch_staged_done) end end set -l f "" for i in $__fish_git_prompt_status_order if test -n "$$i" set -l color_var ___fish_git_prompt_color_$i set -l color_done_var ___fish_git_prompt_color_{$i}_done set -l symbol_var ___fish_git_prompt_char_$i set -l color $$color_var set -l color_done $$color_done_var set -l symbol $$symbol_var set f "$f$color$symbol$color_done" end end set b (string replace refs/heads/ '' -- $b) set -q __fish_git_prompt_shorten_branch_char_suffix or set -l __fish_git_prompt_shorten_branch_char_suffix "…" if string match -qr '^\d+$' "$__fish_git_prompt_shorten_branch_len"; and test (string length "$b") -gt $__fish_git_prompt_shorten_branch_len set b (string sub -l "$__fish_git_prompt_shorten_branch_len" "$b")"$__fish_git_prompt_shorten_branch_char_suffix" end if test -n "$b" set b "$branch_color$b$branch_done" if test -z "$dirtystate$untrackedfiles$stagedstate"; and test -n "$___fish_git_prompt_char_cleanstate" and not set -q __fish_git_prompt_show_informative_status set b "$b$___fish_git_prompt_color_cleanstate$___fish_git_prompt_char_cleanstate$___fish_git_prompt_color_cleanstate_done" end end if test -n "$c" set c "$___fish_git_prompt_color_bare$c$___fish_git_prompt_color_bare_done" end if test -n "$r" set r "$___fish_git_prompt_color_merging$r$___fish_git_prompt_color_merging_done" end if test -n "$p" set p "$___fish_git_prompt_color_upstream$p$___fish_git_prompt_color_upstream_done" end # Formatting if test -n "$f" set f "$space$f" end set -l format $argv[1] if test -z "$format" set format " (%s)" end printf "%s$format%s" "$___fish_git_prompt_color_prefix" "$___fish_git_prompt_color_prefix_done$c$b$f$r$p$informative_status$___fish_git_prompt_color_suffix" "$___fish_git_prompt_color_suffix_done" end ### helper functions function __fish_git_prompt_informative_status set -l stashstate 0 set -l stashfile "$argv[1]/logs/refs/stash" if set -q __fish_git_prompt_showstashstate; and test -e "$stashfile" set stashstate (count < $stashfile) end # If we're not told to show untracked files, we don't. # If we are, we still use the "normal" mode because it's a lot faster, # and it's unlikely anyone cares about the number of files if it's *all* of the files # in that directory. set -l untr -uno test "$untracked" = true and set untr -unormal # Use git status --porcelain. # The v2 format is better, but we don't actually care in this case. set -l stats (string sub -l 2 (git -c core.fsmonitor= status --porcelain -z $untr | string split0)) set -l invalidstate (string match -r '^UU' $stats | count) set -l stagedstate (string match -r '^[ACDMR].' $stats | count) set -l dirtystate (string match -r '^.[ACDMR]' $stats | count) set -l untrackedfiles (string match -r '^\?\?' $stats | count) set -l info # If `math` fails for some reason, assume the state is clean - it's the simpler path set -l state (math $dirtystate + $invalidstate + $stagedstate + $untrackedfiles + $stashstate 2>/dev/null) if test -z "$state" or test "$state" = 0 if test -n "$___fish_git_prompt_char_cleanstate" set info $___fish_git_prompt_color_cleanstate$___fish_git_prompt_char_cleanstate$___fish_git_prompt_color_cleanstate_done end else for i in $__fish_git_prompt_status_order if test $$i != 0 set -l color_var ___fish_git_prompt_color_$i set -l color_done_var ___fish_git_prompt_color_{$i}_done set -l symbol_var ___fish_git_prompt_char_$i set -l color $$color_var set -l color_done $$color_done_var set -l symbol $$symbol_var set -l count if not set -q __fish_git_prompt_hide_$i set count $$i end set info "$info$color$symbol$count$color_done" end end end echo $info end # Keeping these together avoids many duplicated checks function __fish_git_prompt_operation_branch_bare --description "fish_git_prompt helper, returns the current Git operation and branch" # This function is passed the full repo_info array set -l git_dir $argv[1] set -l inside_gitdir $argv[2] set -l bare_repo $argv[3] set -q argv[5] and set -l sha $argv[5] set -l branch set -l operation set -l detached no set -l bare set -l step set -l total if test -d $git_dir/rebase-merge set branch (cat $git_dir/rebase-merge/head-name 2>/dev/null) set step (cat $git_dir/rebase-merge/msgnum 2>/dev/null) set total (cat $git_dir/rebase-merge/end 2>/dev/null) if test -f $git_dir/rebase-merge/interactive set operation "|REBASE-i" else set operation "|REBASE-m" end else if test -d $git_dir/rebase-apply set step (cat $git_dir/rebase-apply/next 2>/dev/null) set total (cat $git_dir/rebase-apply/last 2>/dev/null) if test -f $git_dir/rebase-apply/rebasing set branch (cat $git_dir/rebase-apply/head-name 2>/dev/null) set operation "|REBASE" else if test -f $git_dir/rebase-apply/applying set operation "|AM" else set operation "|AM/REBASE" end else if test -f $git_dir/MERGE_HEAD set operation "|MERGING" else if test -f $git_dir/CHERRY_PICK_HEAD set operation "|CHERRY-PICKING" else if test -f $git_dir/REVERT_HEAD set operation "|REVERTING" else if test -f $git_dir/BISECT_LOG set operation "|BISECTING" end end if test -n "$step" -a -n "$total" set operation "$operation $step/$total" end if test -z "$branch" if not set branch (command git symbolic-ref HEAD 2>/dev/null) set detached yes set branch (switch "$__fish_git_prompt_describe_style" case contains command git describe --contains HEAD case branch command git describe --contains --all HEAD case describe command git describe HEAD case default '*' command git describe --tags --exact-match HEAD end 2>/dev/null) if test $status -ne 0 # Shorten the sha ourselves to 8 characters - this should be good for most repositories, # and even for large ones it should be good for most commits if set -q sha set branch (string match -r '^.{8}' -- $sha)… else set branch unknown end end set branch "($branch)" end end if test true = $inside_gitdir if test true = $bare_repo set bare "BARE:" else # Let user know they're inside the git dir of a non-bare repo set branch "GIT_DIR!" end end echo $operation echo $branch echo $detached echo $bare end function __fish_git_prompt_set_char set -l user_variable_name "$argv[1]" set -l char $argv[2] if set -q argv[3] and begin set -q __fish_git_prompt_show_informative_status or set -q __fish_git_prompt_use_informative_chars end set char $argv[3] end set -l variable _$user_variable_name set -l variable_done "$variable"_done if not set -q $variable set -g $variable (set -q $user_variable_name; and echo $$user_variable_name; or echo $char) end end function __fish_git_prompt_validate_chars --description "fish_git_prompt helper, checks char variables" # cleanstate is only defined with actual informative status. set -q __fish_git_prompt_show_informative_status and __fish_git_prompt_set_char __fish_git_prompt_char_cleanstate '✔' or __fish_git_prompt_set_char __fish_git_prompt_char_cleanstate '' __fish_git_prompt_set_char __fish_git_prompt_char_dirtystate '*' '✚' __fish_git_prompt_set_char __fish_git_prompt_char_invalidstate '#' '✖' __fish_git_prompt_set_char __fish_git_prompt_char_stagedstate '+' '●' __fish_git_prompt_set_char __fish_git_prompt_char_stashstate '$' '⚑' __fish_git_prompt_set_char __fish_git_prompt_char_stateseparator ' ' '|' __fish_git_prompt_set_char __fish_git_prompt_char_untrackedfiles '%' '…' __fish_git_prompt_set_char __fish_git_prompt_char_upstream_ahead '>' '↑' __fish_git_prompt_set_char __fish_git_prompt_char_upstream_behind '<' '↓' __fish_git_prompt_set_char __fish_git_prompt_char_upstream_diverged '<>' __fish_git_prompt_set_char __fish_git_prompt_char_upstream_equal '=' __fish_git_prompt_set_char __fish_git_prompt_char_upstream_prefix '' end function __fish_git_prompt_set_color set -l user_variable_name "$argv[1]" set -l default default_done switch (count $argv) case 1 # No defaults given, use prompt color set default $___fish_git_prompt_color set default_done $___fish_git_prompt_color_done case 2 # One default given, use normal for done set default "$argv[2]" set default_done (set_color normal) case 3 # Both defaults given set default "$argv[2]" set default_done "$argv[3]" end set -l variable _$user_variable_name set -l variable_done "$variable"_done if not set -q $variable if test -n "$$user_variable_name" set -g $variable (set_color $$user_variable_name) set -g $variable_done (set_color normal) else set -g $variable $default set -g $variable_done $default_done end end end function __fish_git_prompt_validate_colors --description "fish_git_prompt helper, checks color variables" # Base color defaults to nothing (must be done first) __fish_git_prompt_set_color __fish_git_prompt_color '' '' # Normal colors __fish_git_prompt_set_color __fish_git_prompt_color_prefix __fish_git_prompt_set_color __fish_git_prompt_color_suffix __fish_git_prompt_set_color __fish_git_prompt_color_bare __fish_git_prompt_set_color __fish_git_prompt_color_merging __fish_git_prompt_set_color __fish_git_prompt_color_cleanstate __fish_git_prompt_set_color __fish_git_prompt_color_invalidstate __fish_git_prompt_set_color __fish_git_prompt_color_upstream # Colors with defaults with showcolorhints if set -q __fish_git_prompt_showcolorhints __fish_git_prompt_set_color __fish_git_prompt_color_flags (set_color --bold blue) __fish_git_prompt_set_color __fish_git_prompt_color_branch (set_color green) __fish_git_prompt_set_color __fish_git_prompt_color_dirtystate (set_color red) __fish_git_prompt_set_color __fish_git_prompt_color_stagedstate (set_color green) else __fish_git_prompt_set_color __fish_git_prompt_color_flags __fish_git_prompt_set_color __fish_git_prompt_color_branch __fish_git_prompt_set_color __fish_git_prompt_color_dirtystate $___fish_git_prompt_color_flags $___fish_git_prompt_color_flags_done __fish_git_prompt_set_color __fish_git_prompt_color_stagedstate $___fish_git_prompt_color_flags $___fish_git_prompt_color_flags_done end # Branch_detached has a default, but is only used with showcolorhints __fish_git_prompt_set_color __fish_git_prompt_color_branch_detached (set_color red) # Colors that depend on flags color __fish_git_prompt_set_color __fish_git_prompt_color_stashstate $___fish_git_prompt_color_flags $___fish_git_prompt_color_flags_done __fish_git_prompt_set_color __fish_git_prompt_color_untrackedfiles $___fish_git_prompt_color_flags $___fish_git_prompt_color_flags_done end function __fish_git_prompt_reset -a type -a op -a var --description "Event handler, resets prompt when functionality changes" \ --on-variable=__fish_git_prompt_{repaint,describe_style,show_informative_status,use_informative_chars,showdirtystate,showstashstate,showuntrackedfiles,showupstream} if status --is-interactive if contains -- $var __fish_git_prompt_show_informative_status __fish_git_prompt_use_informative_chars # Clear characters that have different defaults with/without informative status set -e ___fish_git_prompt_char_{name,cleanstate,dirtystate,invalidstate,stagedstate,stashstate,stateseparator,untrackedfiles,upstream_ahead,upstream_behind} # Clear init so we reset the chars next time. set -e ___fish_git_prompt_init end end end function __fish_git_prompt_reset_color -a type -a op -a var --description "Event handler, resets prompt when any color changes" \ --on-variable=__fish_git_prompt_color{'',_prefix,_suffix,_bare,_merging,_cleanstate,_invalidstate,_upstream,_flags,_branch,_dirtystate,_stagedstate,_branch_detached,_stashstate,_untrackedfiles} --on-variable=__fish_git_prompt_showcolorhints if status --is-interactive set -e _$var set -e _{$var}_done set -e ___fish_git_prompt_init if contains -- $var __fish_git_prompt_color __fish_git_prompt_color_flags __fish_git_prompt_showcolorhints # reset all the other colors too set -e ___fish_git_prompt_color_{prefix,suffix,bare,merging,branch,dirtystate,stagedstate,invalidstate,stashstate,untrackedfiles,upstream,flags}{,_done} end end end function __fish_git_prompt_reset_char -a type -a op -a var --description "Event handler, resets prompt when any char changes" \ --on-variable=__fish_git_prompt_char_{cleanstate,dirtystate,invalidstate,stagedstate,stashstate,stateseparator,untrackedfiles,upstream_ahead,upstream_behind,upstream_diverged,upstream_equal,upstream_prefix} if status --is-interactive set -e ___fish_git_prompt_init set -e _$var end end ================================================ FILE: tests/fish_files/fish_vi_key_bindings.fish ================================================ function fish_vi_key_bindings --description 'vi-like key bindings for fish' if contains -- -h $argv or contains -- --help $argv echo "Sorry but this function doesn't support -h or --help" >&2 return 1 end # Erase all bindings if not explicitly requested otherwise to # allow for hybrid bindings. # This needs to be checked here because if we are called again # via the variable handler the argument will be gone. set -l rebind true if test "$argv[1]" = --no-erase set rebind false set -e argv[1] else bind --erase --all --preset # clear earlier bindings, if any end # Allow just calling this function to correctly set the bindings. # Because it's a rather discoverable name, users will execute it # and without this would then have subtly broken bindings. if test "$fish_key_bindings" != fish_vi_key_bindings and test "$rebind" = true # Allow the user to set the variable universally. set -q fish_key_bindings or set -g fish_key_bindings # This triggers the handler, which calls us again and ensures the user_key_bindings # are executed. set fish_key_bindings fish_vi_key_bindings return end set -l init_mode insert # These are only the special vi-style keys # not end/home, we share those. set -l eol_keys \$ g\$ set -l bol_keys \^ 0 g\^ if contains -- $argv[1] insert default visual set init_mode $argv[1] else if set -q argv[1] # We should still go on so the bindings still get set. echo "Unknown argument $argv" >&2 end # Inherit shared key bindings. # Do this first so vi-bindings win over default. for mode in insert default visual __fish_shared_key_bindings -s -M $mode end # Add a way to switch from insert to normal (command) mode. # Note if we are paging, we want to stay in insert mode # See #2871 bind -s --preset -M insert \e "if commandline -P; commandline -f cancel; else; set fish_bind_mode default; commandline -f backward-char repaint-mode; end" # Default (command) mode bind -s --preset :q exit bind -s --preset -m insert \cc cancel-commandline repaint-mode bind -s --preset -M default h backward-char bind -s --preset -M default l forward-char bind -s --preset -m insert \n execute bind -s --preset -m insert \r execute bind -s --preset -m insert o insert-line-under repaint-mode bind -s --preset -m insert O insert-line-over repaint-mode bind -s --preset -m insert i repaint-mode bind -s --preset -m insert I beginning-of-line repaint-mode bind -s --preset -m insert a forward-single-char repaint-mode bind -s --preset -m insert A end-of-line repaint-mode bind -s --preset -m visual v begin-selection repaint-mode #bind -s --preset -m insert o "commandline -a \n" down-line repaint-mode #bind -s --preset -m insert O beginning-of-line "commandline -i \n" up-line repaint-mode # doesn't work bind -s --preset gg beginning-of-buffer bind -s --preset G end-of-buffer for key in $eol_keys bind -s --preset $key end-of-line end for key in $bol_keys bind -s --preset $key beginning-of-line end bind -s --preset u undo bind -s --preset \cr redo bind -s --preset [ history-token-search-backward bind -s --preset ] history-token-search-forward bind -s --preset k up-or-search bind -s --preset j down-or-search bind -s --preset b backward-word bind -s --preset B backward-bigword bind -s --preset ge backward-word bind -s --preset gE backward-bigword bind -s --preset w forward-word forward-single-char bind -s --preset W forward-bigword forward-single-char bind -s --preset e forward-single-char forward-word backward-char bind -s --preset E forward-bigword backward-char # Vi/Vim doesn't support these keys in insert mode but that seems silly so we do so anyway. bind -s --preset -M insert -k home beginning-of-line bind -s --preset -M default -k home beginning-of-line bind -s --preset -M insert -k end end-of-line bind -s --preset -M default -k end end-of-line # Vi moves the cursor back if, after deleting, it is at EOL. # To emulate that, move forward, then backward, which will be a NOP # if there is something to move forward to. bind -s --preset -M default x delete-char forward-single-char backward-char bind -s --preset -M default X backward-delete-char bind -s --preset -M insert -k dc delete-char forward-single-char backward-char bind -s --preset -M default -k dc delete-char forward-single-char backward-char # Backspace deletes a char in insert mode, but not in normal/default mode. bind -s --preset -M insert -k backspace backward-delete-char bind -s --preset -M default -k backspace backward-char bind -s --preset -M insert \ch backward-delete-char bind -s --preset -M default \ch backward-char bind -s --preset -M insert \x7f backward-delete-char bind -s --preset -M default \x7f backward-char bind -s --preset -M insert -k sdc backward-delete-char # shifted delete bind -s --preset -M default -k sdc backward-delete-char # shifted delete bind -s --preset dd kill-whole-line bind -s --preset D kill-line bind -s --preset d\$ kill-line bind -s --preset d\^ backward-kill-line bind -s --preset d0 backward-kill-line bind -s --preset dw kill-word bind -s --preset dW kill-bigword bind -s --preset diw forward-single-char forward-single-char backward-word kill-word bind -s --preset diW forward-single-char forward-single-char backward-bigword kill-bigword bind -s --preset daw forward-single-char forward-single-char backward-word kill-word bind -s --preset daW forward-single-char forward-single-char backward-bigword kill-bigword bind -s --preset de kill-word bind -s --preset dE kill-bigword bind -s --preset db backward-kill-word bind -s --preset dB backward-kill-bigword bind -s --preset dge backward-kill-word bind -s --preset dgE backward-kill-bigword bind -s --preset df begin-selection forward-jump kill-selection end-selection bind -s --preset dt begin-selection forward-jump backward-char kill-selection end-selection bind -s --preset dF begin-selection backward-jump kill-selection end-selection bind -s --preset dT begin-selection backward-jump forward-single-char kill-selection end-selection bind -s --preset dh backward-char delete-char bind -s --preset dl delete-char bind -s --preset di backward-jump-till and repeat-jump-reverse and begin-selection repeat-jump kill-selection end-selection bind -s --preset da backward-jump and repeat-jump-reverse and begin-selection repeat-jump kill-selection end-selection bind -s --preset 'd;' begin-selection repeat-jump kill-selection end-selection bind -s --preset 'd,' begin-selection repeat-jump-reverse kill-selection end-selection bind -s --preset -m insert s delete-char repaint-mode bind -s --preset -m insert S kill-inner-line repaint-mode bind -s --preset -m insert cc kill-inner-line repaint-mode bind -s --preset -m insert C kill-line repaint-mode bind -s --preset -m insert c\$ kill-line repaint-mode bind -s --preset -m insert c\^ backward-kill-line repaint-mode bind -s --preset -m insert c0 backward-kill-line repaint-mode bind -s --preset -m insert cw kill-word repaint-mode bind -s --preset -m insert cW kill-bigword repaint-mode bind -s --preset -m insert ciw forward-single-char forward-single-char backward-word kill-word repaint-mode bind -s --preset -m insert ciW forward-single-char forward-single-char backward-bigword kill-bigword repaint-mode bind -s --preset -m insert caw forward-single-char forward-single-char backward-word kill-word repaint-mode bind -s --preset -m insert caW forward-single-char forward-single-char backward-bigword kill-bigword repaint-mode bind -s --preset -m insert ce kill-word repaint-mode bind -s --preset -m insert cE kill-bigword repaint-mode bind -s --preset -m insert cb backward-kill-word repaint-mode bind -s --preset -m insert cB backward-kill-bigword repaint-mode bind -s --preset -m insert cge backward-kill-word repaint-mode bind -s --preset -m insert cgE backward-kill-bigword repaint-mode bind -s --preset -m insert cf begin-selection forward-jump kill-selection end-selection repaint-mode bind -s --preset -m insert ct begin-selection forward-jump backward-char kill-selection end-selection repaint-mode bind -s --preset -m insert cF begin-selection backward-jump kill-selection end-selection repaint-mode bind -s --preset -m insert cT begin-selection backward-jump forward-single-char kill-selection end-selection repaint-mode bind -s --preset -m insert ch backward-char begin-selection kill-selection end-selection repaint-mode bind -s --preset -m insert cl begin-selection kill-selection end-selection repaint-mode bind -s --preset -m insert ci backward-jump-till and repeat-jump-reverse and begin-selection repeat-jump kill-selection end-selection repaint-mode bind -s --preset -m insert ca backward-jump and repeat-jump-reverse and begin-selection repeat-jump kill-selection end-selection repaint-mode bind -s --preset '~' togglecase-char forward-single-char bind -s --preset gu downcase-word bind -s --preset gU upcase-word bind -s --preset J end-of-line delete-char bind -s --preset K 'man (commandline -t) 2>/dev/null; or echo -n \a' bind -s --preset yy kill-whole-line yank bind -s --preset Y kill-whole-line yank bind -s --preset y\$ kill-line yank bind -s --preset y\^ backward-kill-line yank bind -s --preset y0 backward-kill-line yank bind -s --preset yw kill-word yank bind -s --preset yW kill-bigword yank bind -s --preset yiw forward-single-char forward-single-char backward-word kill-word yank bind -s --preset yiW forward-single-char forward-single-char backward-bigword kill-bigword yank bind -s --preset yaw forward-single-char forward-single-char backward-word kill-word yank bind -s --preset yaW forward-single-char forward-single-char backward-bigword kill-bigword yank bind -s --preset ye kill-word yank bind -s --preset yE kill-bigword yank bind -s --preset yb backward-kill-word yank bind -s --preset yB backward-kill-bigword yank bind -s --preset yge backward-kill-word yank bind -s --preset ygE backward-kill-bigword yank bind -s --preset yf begin-selection forward-jump kill-selection yank end-selection bind -s --preset yt begin-selection forward-jump-till kill-selection yank end-selection bind -s --preset yF begin-selection backward-jump kill-selection yank end-selection bind -s --preset yT begin-selection backward-jump-till kill-selection yank end-selection bind -s --preset yh backward-char begin-selection kill-selection yank end-selection bind -s --preset yl begin-selection kill-selection yank end-selection bind -s --preset yi backward-jump-till and repeat-jump-reverse and begin-selection repeat-jump kill-selection yank end-selection bind -s --preset ya backward-jump and repeat-jump-reverse and begin-selection repeat-jump kill-selection yank end-selection bind -s --preset f forward-jump bind -s --preset F backward-jump bind -s --preset t forward-jump-till bind -s --preset T backward-jump-till bind -s --preset ';' repeat-jump bind -s --preset , repeat-jump-reverse # in emacs yank means paste # in vim p means paste *after* current character, so go forward a char before pasting # also in vim, P means paste *at* current position (like at '|' with cursor = line), # \ so there's no need to go back a char, just paste it without moving bind -s --preset p forward-char yank bind -s --preset P yank bind -s --preset gp yank-pop # same vim 'pasting' note as upper bind -s --preset '"*p' forward-char "commandline -i ( xsel -p; echo )[1]" bind -s --preset '"*P' "commandline -i ( xsel -p; echo )[1]" # # Lowercase r, enters replace_one mode # bind -s --preset -m replace_one r repaint-mode bind -s --preset -M replace_one -m default '' delete-char self-insert backward-char repaint-mode bind -s --preset -M replace_one -m default \r 'commandline -f delete-char; commandline -i \n; commandline -f backward-char; commandline -f repaint-mode' bind -s --preset -M replace_one -m default \e cancel repaint-mode # # Uppercase R, enters replace mode # bind -s --preset -m replace R repaint-mode bind -s --preset -M replace '' delete-char self-insert bind -s --preset -M replace -m insert \r execute repaint-mode bind -s --preset -M replace -m default \e cancel repaint-mode # in vim (and maybe in vi), deletes the changes # but this binding just move cursor backward, not delete the changes bind -s --preset -M replace -k backspace backward-char # # visual mode # bind -s --preset -M visual h backward-char bind -s --preset -M visual l forward-char bind -s --preset -M visual k up-line bind -s --preset -M visual j down-line bind -s --preset -M visual b backward-word bind -s --preset -M visual B backward-bigword bind -s --preset -M visual ge backward-word bind -s --preset -M visual gE backward-bigword bind -s --preset -M visual w forward-word bind -s --preset -M visual W forward-bigword bind -s --preset -M visual e forward-word bind -s --preset -M visual E forward-bigword bind -s --preset -M visual o swap-selection-start-stop repaint-mode bind -s --preset -M visual f forward-jump bind -s --preset -M visual t forward-jump-till bind -s --preset -M visual F backward-jump bind -s --preset -M visual T backward-jump-till for key in $eol_keys bind -s --preset -M visual $key end-of-line end for key in $bol_keys bind -s --preset -M visual $key beginning-of-line end bind -s --preset -M visual -m insert c kill-selection end-selection repaint-mode bind -s --preset -M visual -m insert s kill-selection end-selection repaint-mode bind -s --preset -M visual -m default d kill-selection end-selection repaint-mode bind -s --preset -M visual -m default x kill-selection end-selection repaint-mode bind -s --preset -M visual -m default X kill-whole-line end-selection repaint-mode bind -s --preset -M visual -m default y kill-selection yank end-selection repaint-mode bind -s --preset -M visual -m default '"*y' "fish_clipboard_copy; commandline -f end-selection repaint-mode" bind -s --preset -M visual -m default '~' togglecase-selection end-selection repaint-mode bind -s --preset -M visual -m default \cc end-selection repaint-mode bind -s --preset -M visual -m default \e end-selection repaint-mode # Make it easy to turn an unexecuted command into a comment in the shell history. Also, remove # the commenting chars so the command can be further edited then executed. bind -s --preset -M default \# __fish_toggle_comment_commandline bind -s --preset -M visual \# __fish_toggle_comment_commandline bind -s --preset -M replace \# __fish_toggle_comment_commandline # Set the cursor shape # After executing once, this will have defined functions listening for the variable. # Therefore it needs to be before setting fish_bind_mode. fish_vi_cursor set fish_bind_mode $init_mode end ================================================ FILE: tests/fish_files/help.fish ================================================ function help --description 'Show help for the fish shell' set -l options h/help argparse -n help $options -- $argv or return if set -q _flag_help __fish_print_help help return 0 end set -l fish_help_item $argv[1] if test (count $argv) -gt 1 if string match -q string $argv[1] set fish_help_item (string join '-' $argv[1] $argv[2]) else echo "help: Expected at most 1 args, got 2" >&2 return 1 end end # Find a suitable browser for viewing the help pages. # The first thing we try is $fish_help_browser. set -l fish_browser $fish_help_browser # A list of graphical browsers we know about. set -l graphical_browsers htmlview x-www-browser firefox galeon mozilla xdg-open set -a graphical_browsers konqueror epiphany opera netscape rekonq google-chrome chromium-browser # On mac we may have to write a temporary file that redirects to the desired # help page, since `open` will drop fragments from file URIs (issue #4480). set -l need_trampoline if not set -q fish_browser[1] if set -q BROWSER # User has manually set a preferred browser, so we respect that echo $BROWSER | read -at fish_browser else # No browser set up, inferring. # We check a bunch and use the last we find. # Check for a text-based browser. for i in htmlview www-browser links elinks lynx w3m if type -q -f $i set fish_browser $i break end end # If we are in a graphical environment, check if there is a graphical # browser to use instead. if test -n "$DISPLAY" -a \( "$XAUTHORITY" = "$HOME/.Xauthority" -o "$XAUTHORITY" = "" \) for i in $graphical_browsers if type -q -f $i set fish_browser $i break end end end # If we have an open _command_ we use it - otherwise it's our function, # which might not have a backend to use. # Note that we prefer xdg-open, because this open might also be a symlink to "openvt" # like it is on Debian. if command -sq open set fish_browser open # The open command needs a trampoline because the macOS version can't handle #-fragments. set need_trampoline 1 end # If the OS appears to be Windows (graphical), try to use cygstart if type -q cygstart set fish_browser cygstart # If xdg-open is available, just use that else if type -q xdg-open set fish_browser xdg-open end # Try to find cmd.exe via $PATH or one of the paths that it's often at. # # We use this instead of xdg-open because that's useless without a backend # like wsl-open which we'll check in a minute. if test -f /proc/version and string match -riq 'Microsoft|WSL|MSYS|MINGW' &2 printf (_ 'Please try `BROWSER=some_browser help`, `man fish-doc`, or `man fish-tutorial`.\n\n') >&2 return 1 end # In Cygwin, start the user-specified browser using cygstart, # only if a Windows browser is to be used. if type -q cygstart if test $fish_browser != cygstart and not command -sq $fish_browser[1] # Escaped quotes are necessary to work with spaces in the path # when the command is finally eval'd. set fish_browser cygstart $fish_browser else set need_trampoline 1 end end # HACK: Hardcode all section titles for each page. # This could possibly be automated. set -l intropages introduction where-to-go installation starting-and-exiting default-shell uninstalling shebang-line configuration examples resources other-help-pages set -l for_bash_pages arithmetic-expansion bash-command-substitutions blocks-and-loops builtins-and-other-commands command-substitutions fish-for-bash-users heredocs process-substitution prompts quoting special-variables string-manipulation subshells test-test variables wildcards-globs set -l faqpages faq-ssh-interactive faq-unicode faq-uninstalling frequently-asked-questions how-can-i-use-as-a-shortcut-for-cd how-do-i-change-the-greeting-message how-do-i-check-whether-a-variable-is-defined how-do-i-check-whether-a-variable-is-not-empty how-do-i-customize-my-syntax-highlighting-colors how-do-i-get-the-exit-status-of-a-command how-do-i-make-fish-my-default-shell how-do-i-run-a-command-every-login-what-s-fish-s-equivalent-to-bashrc-or-profile how-do-i-run-a-command-from-history how-do-i-run-a-subcommand-the-backtick-doesn-t-work how-do-i-set-my-prompt how-do-i-set-or-clear-an-environment-variable i-accidentally-entered-a-directory-path-and-fish-changed-directory-what-happened i-m-getting-weird-graphical-glitches-a-staircase-effect-ghost-characters-cursor-in-the-wrong-position i-m-seeing-weird-output-before-each-prompt-when-using-screen-what-s-wrong my-command-pkg-config-gives-its-output-as-a-single-long-string my-command-prints-no-matches-for-wildcard-but-works-in-bash the-open-command-doesn-t-work uninstalling-fish what-is-the-equivalent-to-this-thing-from-bash-or-other-shells where-can-i-find-extra-tools-for-fish why-does-my-prompt-show-a-i why-doesn-t-history-substitution-etc-work why-doesn-t-set-ux-exported-universal-variables-seem-to-work why-won-t-ssh-scp-rsync-connect-properly-when-fish-is-my-login-shell set -l interactivepages abbreviations autosuggestions color command-line-editor command-mode configurable-greeting copy-and-paste-kill-ring custom-bindings custom-binds directory-stack editor emacs-mode emacs-mode-commands greeting help history-search id7 insert-mode interactive interactive-use killring multiline multiline-editing navigating-directories pager-color-variables private-mode programmable-prompt programmable-title prompt searchable-command-history shared-bindings shared-binds syntax-highlighting syntax-highlighting-variables tab-completion title variables-color variables-color-pager vi-mode vi-mode-command vi-mode-commands vi-mode-insert vi-mode-visual visual-mode set -l langpages argument-handling autoloading-functions brace-expansion builtin-commands builtin-overview cartesian-product combine combining-different-expansions combining-lists-cartesian-product command-substitution comments conditions debugging debugging-fish-scripts defining-aliases escapes escaping-characters event event-handlers expand expand-brace expand-command-substitution expand-home expand-index-range expand-variable expand-wildcard exporting-variables featureflags functions future-feature-flags home-directory-expansion identifiers index-range-expansion input-output-redirection job-control language lists locale-variables loops-and-blocks more-on-universal-variables overriding-variables-for-a-single-command parameter-expansion path-variables pipes piping quotes redirects shell-variable-and-function-names shell-variables special-variables syntax syntax-conditional syntax-function syntax-function-autoloading syntax-function-wrappers syntax-job-control syntax-loops-and-blocks syntax-overview terminology the-fish-language the-status-variable variable-expansion variables variables-argv variable-scope variable-scope-for-functions variables-export variables-functions variables-lists variables-locale variables-override variables-path variables-scope variables-special variables-status variables-universal wildcards-globbing configuration set -l tutpages autoloading-functions autosuggestions combiners-and-or-not command-substitutions conditionals-if-else-switch exit-status exports-shell-variables functions getting-help getting-started learning-fish lists loops pipes-and-redirections prompt ready-for-more running-commands separating-commands-semicolon startup-where-s-bashrc switching-to-fish syntax-highlighting tab-completions tut-combiners tut-conditionals tut-config tut-exports tut-lists tutorial tut-semicolon tut-universal universal-variables variables why-fish wildcards set -l fish_help_page switch "$fish_help_item" case "." set fish_help_page "cmds/source.html" case globbing set fish_help_page "language.html#expand" case 'completion-*' set fish_help_page "completions.html#$fish_help_item" case 'tut-*' set fish_help_page "tutorial.html#"(string sub -s 5 -- $fish_help_item | string replace -a -- _ -) case tutorial set fish_help_page "tutorial.html" case releasenotes set fish_help_page relnotes.html case completions set fish_help_page completions.html case commands set fish_help_page commands.html case faq set fish_help_page faq.html case fish-for-bash-users set fish_help_page fish_for_bash_users.html case $faqpages set fish_help_page "faq.html#$fish_help_item" case $for_bash_pages set fish_help_page "fish_for_bash_users.html#$fish_help_item" case $langpages set fish_help_page "language.html#$fish_help_item" case $interactivepages set fish_help_page "interactive.html#$fish_help_item" case $tutpages set fish_help_page "tutorial.html#$fish_help_item" case (builtin -n) (__fish_print_commands) # If the docs aren't installed, __fish_print_commands won't print anything # Since we document all our builtins, check those at least. # The alternative is to create this list at build time. set fish_help_page "cmds/$fish_help_item.html" case '' set fish_help_page "index.html" case $intropages set fish_help_page "index.html$fish_help_item" case "*" printf (_ "%s: no fish help topic '%s', try 'man %s'\n") help $fish_help_item $fish_help_item return 1 end # In Crostini Chrome OS Linux, the default browser opens URLs in Chrome running outside the # linux VM. This browser does not have access to the Linux filesystem. This uses Garcon, see e.g. # https://chromium.googlesource.com/chromiumos/platform2/+/master/vm_tools/garcon/#opening-urls # https://source.chromium.org/search?q=garcon-url-handler string match -q '*garcon-url-handler*' $fish_browser[1] and set -l chromeos_linux_garcon set -l page_url if test -f $__fish_help_dir/index.html; and not set -lq chromeos_linux_garcon # Help is installed, use it set page_url file://$__fish_help_dir/$fish_help_page # For Windows (Cygwin, msys2 and WSL), we need to convert the base # help dir to a Windows path before converting it to a file URL # but only if a Windows browser is being used if type -q cygpath and string match -qr '(cygstart|\.exe)(\s+|$)' $fish_browser[1] set page_url file://(cygpath -m $__fish_help_dir)/$fish_help_page else if type -q wslpath and string match -qr '\.exe(\s+|$)' $fish_browser[1] set page_url file://(wslpath -w $__fish_help_dir)/$fish_help_page end else # Go to the web. Only include one dot in the version string set -l version_string (string split . -f 1,2 -- $version | string join .) set page_url https://fishshell.com/docs/$version_string/$fish_help_page # We don't need a trampoline for a remote URL. set need_trampoline end if set -q need_trampoline[1] # If string replace doesn't replace anything, we don't actually need a # trampoline (they're only needed if there's a fragment in the path) if set -l clean_url (string match -re '#' $page_url) # Write a temporary file that will redirect where we want. set -q TMPDIR or set -l TMPDIR /tmp set -l tmpdir (mktemp -d $TMPDIR/help.XXXXXX) or return 1 set -l tmpname $tmpdir/help.html echo '' >$tmpname set page_url file://$tmpname # For Windows (Cygwin, msys2 and WSL), we need to convert the base help dir to a Windows path before converting it to a file URL # but only if a Windows browser is being used if type -q cygpath and string match -qr '(cygstart|\.exe)(\s+|$)' $fish_browser[1] set page_url file://(cygpath -m $tmpname) else if type -q wslpath and string match -qr '\.exe(\s+|$)' $fish_browser[1] set page_url file://(wslpath -w $tmpname) end end end # cmd.exe needs more coaxing. if string match -qr 'cmd\.exe$' -- $fish_browser[1] # The space before the /c is to prevent msys2 from expanding it to a path $fish_browser " /c" start $page_url # If browser is known to be graphical, put into background else if contains -- $fish_browser[1] $graphical_browsers switch $fish_browser[1] case htmlview x-www-browser printf (_ 'help: Help is being displayed in your default browser.\n') case '*' printf (_ 'help: Help is being displayed in %s.\n') $fish_browser[1] end $fish_browser $page_url & disown $last_pid >/dev/null 2>&1 else # Work around lynx bug where
always has the same formatting as links (unreadable) # by using a custom style sheet. See https://github.com/fish-shell/fish-shell/issues/4170 if string match -qr '^lynx' -- $fish_browser set fish_browser $fish_browser -lss={$__fish_data_dir}/lynx.lss end $fish_browser $page_url end end ================================================ FILE: tests/fish_files/history.fish ================================================ # # Wrap the builtin history command to provide additional functionality. # function __fish_unexpected_hist_args --no-scope-shadowing if test -n "$search_mode" or set -q show_time[1] printf (_ "%ls: %ls: subcommand takes no options\n") $cmd $hist_cmd >&2 return 0 end if set -q argv[1] printf (_ "%ls: %ls: expected %d arguments; got %d\n") $cmd $hist_cmd 0 (count $argv) >&2 return 0 end return 1 end function history --description "display or manipulate interactive command history" set -l cmd history set -l options --exclusive 'c,e,p' --exclusive 'S,D,M,V,X' set -a options h/help c/contains e/exact p/prefix set -a options C/case-sensitive R/reverse z/null 't/show-time=?' 'n#max' # The following options are deprecated and will be removed in the next major release. # Note that they do not have usable short flags. set -a options S-search D-delete M-merge V-save X-clear argparse -n $cmd $options -- $argv or return if set -q _flag_help __fish_print_help history return 0 end set -l hist_cmd set -l show_time set -l max_count set -l search_mode set -q _flag_max set max_count -n$_flag_max set -q _flag_with_time and set -l _flag_show_time $_flag_with_time if set -q _flag_show_time[1] set show_time --show-time=$_flag_show_time else if set -q _flag_show_time set show_time --show-time end set -q _flag_prefix and set -l search_mode --prefix set -q _flag_contains and set -l search_mode --contains set -q _flag_exact and set -l search_mode --exact if set -q _flag_delete set hist_cmd delete else if set -q _flag_save set hist_cmd save else if set -q _flag_clear set hist_cmd clear else if set -q _flag_search set hist_cmd search else if set -q _flag_merge set hist_cmd merge else if set -q _flag_clear-session set hist_cmd clear-session end # If a history command has not already been specified check the first non-flag argument for a # command. This allows the flags to appear before or after the subcommand. if not set -q hist_cmd[1] and set -q argv[1] if contains $argv[1] search delete merge save clear clear-session set hist_cmd $argv[1] set -e argv[1] end end if not set -q hist_cmd[1] set hist_cmd search # default to "search" if the user didn't specify a subcommand end switch $hist_cmd case search # search the interactive command history test -z "$search_mode" and set search_mode --contains if isatty stdout set -l pager less set -q PAGER and echo $PAGER | read -at pager # If the user hasn't preconfigured less with the $LESS environment variable, # we do so to have it behave like cat if output fits on one screen. if not set -qx LESS set -x LESS --quit-if-one-screen # Also set --no-init for less < v530, see #8157. if test (less --version | string match -r 'less (\d+)')[2] -lt 530 2>/dev/null set -x LESS $LESS --no-init end end not set -qx LV # ask the pager lv not to strip colors and set -x LV -c builtin history search $search_mode $show_time $max_count $_flag_case_sensitive $_flag_reverse $_flag_null -- $argv | $pager else builtin history search $search_mode $show_time $max_count $_flag_case_sensitive $_flag_reverse $_flag_null -- $argv end case delete # interactively delete history # TODO: Fix this to deal with history entries that have multiple lines. set -l searchterm $argv if not set -q argv[1] read -P"Search term: " searchterm end if test -z "$search_mode" set search_mode --contains end if test $search_mode = --exact builtin history delete $search_mode $_flag_case_sensitive -- $searchterm return end # TODO: Fix this so that requesting history entries with a timestamp works: # set -l found_items (builtin history search $search_mode $show_time -- $argv) set -l found_items set found_items (builtin history search $search_mode $_flag_case_sensitive --null -- $searchterm | string split0) if set -q found_items[1] set -l found_items_count (count $found_items) for i in (seq $found_items_count) printf "[%s] %s\n" $i $found_items[$i] end echo "" echo "Enter nothing to cancel the delete, or" echo "Enter one or more of the entry IDs separated by a space, or" echo "Enter \"all\" to delete all the matching entries." echo "" read --local --prompt "echo 'Delete which entries? > '" choice echo '' if test -z "$choice" printf "Cancelling the delete!\n" return end if test "$choice" = all printf "Deleting all matching entries!\n" for item in $found_items builtin history delete --exact --case-sensitive -- $item end builtin history save return end for i in (string split " " -- $choice) if test -z "$i" or not string match -qr '^[1-9][0-9]*$' -- $i or test $i -gt $found_items_count printf "Ignoring invalid history entry ID \"%s\"\n" $i continue end printf "Deleting history entry %s: \"%s\"\n" $i $found_items[$i] builtin history delete --exact --case-sensitive -- "$found_items[$i]" end builtin history save end case save # save our interactive command history to the persistent history __fish_unexpected_hist_args $argv and return 1 builtin history save -- $argv case merge # merge the persistent interactive command history with our history __fish_unexpected_hist_args $argv and return 1 builtin history merge -- $argv case clear # clear the interactive command history __fish_unexpected_hist_args $argv and return 1 printf (_ "If you enter 'yes' your entire interactive command history will be erased\n") read --local --prompt "echo 'Are you sure you want to clear history? (yes/no) '" choice if test "$choice" = yes builtin history clear -- $argv and printf (_ "Command history cleared!\n") else printf (_ "You did not say 'yes' so I will not clear your command history\n") end case clear-session # clears only session __fish_unexpected_hist_args $argv and return 1 builtin history clear-session -- $argv printf (_ "Command history for session cleared!\n") case '*' printf "%ls: unexpected subcommand '%ls'\n" $cmd $hist_cmd return 2 end end ================================================ FILE: tests/fish_files/huge_file.fish ================================================ # sets prompt color to a random color # stores the color in __random_color variable #future goals # • allow for more normal syntax when cmd is specified [ LOCATED IN ---> __set_cmd_for_color ] # • implement rainbow feature # • add more color support # • add flag --echo-n # • add flag to specify a default color [when other color is chosen] # • extend help messages set random_color_array_normal (echo red green yellow blue magenta cyan white) set random_color_array_bright (echo brred brgreen bryellow brblue brmagenta brcyan brwhite) set random_color_array_light (echo white "87afff" "d787ff" "5fff87" "87d7ff" "d7d7ff" ) set random_color_array_dark (echo "00005f" "5f00d7" "5f00ff" "ff0087" "ff00ff" "000000") set random_color_array_error (echo red "af0000" "af00ff" "875fff" "ff00ff" "ff87ff") set colors_16_array (echo black red green purple blue magenta cyan white brblack brred brgreen brpurple brblue brmagenta brcyan brwhite) function set_random_color -d "sets the terminal color to a random color" # declare variables set -l special_flag "" set -l reset_flag_is_set 0 set -l cmd_after_color_change "" set -l flag_is_set 0 set -l cmd_is_set 0 set -l flag_amount 0 set -l debug_flag_is_set 0 set -l change_color_cmd set -l rainbow_found (__has_rainbow_flag $argv) # check if help flag is seen set -l help_found (has_help_arg $argv) if test "$help_found" = "1"; __help_message end #set -l show_colors_found (__has_show_colors_flag $argv) #if test "$show_colors_found[1]" = "1" # __print_colors $show_colors_found[2]; # return 0; #end # set variables if test (count $argv) -ge 1 set special_flag (__set_special_flags $argv) set flag_amount (__check_for_leading_flags $argv) if test $flag_amount -eq 0 set flag_amount (count $argv); end set reset_flag_is_set (__has_reset_flag $argv) set cmd_is_set (__has_command_in_stdin $argv) set cmd_after_color_change (__set_cmd_for_color $argv) set debug_flag_is_set (__has_debug_flag $argv) end # set random color # __check_color_prefs_flag -> checks if any color preferences are passed in as flag # and returns the color preferences array set -l colors (string split " " (__check_color_prefs_flag $argv)); set -l idx (random 1 7) if not set -q $__random_color set -l old_color (echo "$__random_color") set -l new_color (echo "$colors[$idx]") while true; set new_color (string replace "br" "" $new_color) set old_color (string replace "br" "" $old_color) if string match -raq "$new_color" "$old_color" set idx (random 1 7) set new_color (echo "$colors[$idx]") continue; else break; end end end set current_color $colors[$idx] set_color $colors[$idx]; set -g __random_color (echo "$colors[$idx]") # fix special_flag formatting set special_flag (__fix_special_flag_formatting $special_flag) # fix current_color formatting set current_color (string trim -- $current_color) if test $flag_amount -ge 1;and test "$special_flag" != ""; set -l color_cmd_string (echo "set_color $__random_color $special_flag"); set change_color_cmd (string replace -ra " " " " $color_cmd_string); else set -l color_cmd_string (echo "set_color $__random_color"); set change_color_cmd (string replace -ra " " " " $color_cmd_string); end if test $flag_amount -ge 1;or test $cmd_is_set -eq 1 if test $reset_flag_is_set -eq 1;and test $flag_amount -eq 1 set_color_normal; else if test $reset_flag_is_set -eq 1;and test $cmd_is_set -eq 1 eval $change_color_cmd; eval $cmd_after_color_change; set_color_normal; else if test $reset_flag_is_set -eq 0;and test $cmd_is_set -eq 1 eval $change_color_cmd; eval $cmd_after_color_change; else if test $reset_flag_is_set -eq 0;and test $flag_amount -gt 1 eval $change_color_cmd; else eval $change_color_cmd; end else eval $change_color_cmd; end if test $debug_flag_is_set -eq 1 echo "current_color: "$current_color else if test $debug_flag_is_set -eq 2 __debug_dump_all; end end function __typeof_random_color_flag set -l random_color_flag (string replace -ra "-" "" -- $argv) if test $random_color_flag = "0"; or test $random_color_flag = "background"; or test $random_color_flag = "back"; or test $random_color_flag = "B" echo 0; else if test $random_color_flag = "1"; or test $random_color_flag = "bold"; or test $random_color_flag = "b" echo 1; else if test $random_color_flag = "2"; or test $random_color_flag = "italic"; or test $random_color_flag = "italics"; or test $random_color_flag = "i" echo 2; else if test $random_color_flag = "3"; or test $random_color_flag = "underline"; or test $random_color_flag = "u"; echo 3; else if test $random_color_flag = "4"; or test $random_color_flag = "reset";or test $random_color_flag = "normal";or test $random_color_flag = "n";or test $random_color_flag = "r" echo 4; else if test $random_color_flag = "5";or string match -raq "echo=" "$random_color_flag";or test $random_color_flag = "e" echo 5; else if test $random_color_flag = "6"; or string match -raq "command=" "$random_color_flag"; or test $random_color_flag = "c" echo 6; else if test "$argv" = "--"; echo 7; else if test $random_color_flag = "7"; or test $random_color_flag = "debug"; or test $random_color_flag = "dump"; or test $random_color_flag = "d"; echo 8; else if test $random_color_flag = "8"; or test $random_color_flag = "debug-all"; or test $random_color_flag = "dump-all"; or test $random_color_flag = "D"; echo 9; else if test $random_color_flag = "bright"; echo 10; else if test $random_color_flag = "light"; echo 11; else if test $random_color_flag = "dark"; echo 12; else if test $random_color_flag = "error"; echo 13; else if test $random_color_flag = "rainbow"; echo 14; else echo -1; end; end function __remove_rainbow_flag set -l ret_arr for i in (seq 1 (count $argv)) set -l curr_flag (echo $argv[$i]) set -l curr_flag_type (__typeof_random_color_flag $curr_flag) if test $curr_flag_type -ne 14; set -a ret_arr $curr_flag end end echo $ret_arr; end function __has_rainbow_flag set -l has_flag 0 for i in (seq 1 (count $argv)) set -l curr_flag (echo $argv[$i]) set -l curr_flag_type (__typeof_random_color_flag $curr_flag) if test $curr_flag_type -eq 14; set has_flag 1; end end echo $has_flag; end function __get_flag_for_random_color set -l argv (string replace -ra "-" "" -- $argv) set -l is_color_flag (__typeof_random_color_flag $argv) if test $is_color_flag -eq 0; echo "--reverse"; else if test $is_color_flag -eq 1; echo "--bold" else if test $is_color_flag -eq 2; echo "--italics" else if test $is_color_flag -eq 3; echo "--underline" else echo "" end end function __has_reset_flag for i in (seq 1 (count $argv)) set -l curr_flag (__typeof_random_color_flag $argv[$i]) if test $curr_flag -eq 4; echo 1; return; end end echo 0; and return; end function __has_command_in_stdin #echo $argv for i in (seq 1 (count $argv)) #set -l curr_flag_type (string replace -ra "command=" "" -- $argv[$i]) set -l curr_flag_type (__typeof_random_color_flag $argv[$i]) if test $curr_flag_type -eq 5;or test $curr_flag_type -eq 6;or test $curr_flag_type -eq 7; echo 1;return; end end echo 0;return; end function __set_special_flags set -l flag_amount (__check_for_leading_flags $argv) set -l special_flag_arr for i in (seq 1 $flag_amount) set -l curr_flag $argv[$i] set -l curr_flag_type (__typeof_random_color_flag $curr_flag) if test $curr_flag_type -eq 0 set -a special_flag_arr (echo "--reverse") else if test $curr_flag_type -eq 1 set -a special_flag_arr (echo "--bold") else if test $curr_flag_type -eq 2 set -a special_flag_arr (echo "--italics") else if test $curr_flag_type -eq 3 set -a special_flag_arr (echo "--underline") end end echo $special_flag_arr; return; end # here is where we define convert the possible flags: # --command="..." # or # --echo="..." function __set_cmd_for_color set -l upper_limit (count $argv) for i in (seq 1 $upper_limit) set -l curr_flag $argv[$i] set -l curr_flag_type (__typeof_random_color_flag $curr_flag) if test $curr_flag_type -eq 7; set -l cmd_idx (math $i) echo $argv[$cmd_idx..-1]; return; else if test $curr_flag_type -eq 5 -o $curr_flag_type -eq 6 set curr_flag (string replace -ra '"' "" -- $curr_flag) set curr_flag (string replace -ra "'" "" -- $curr_flag) set curr_flag (string replace -ra ".*=" "" -- $curr_flag) set curr_flag (string trim --left $curr_flag) if test $curr_flag_type -eq 5 ; echo 'echo -e "$curr_flag"'; else echo "$curr_flag"; end end end end function __has_debug_flag for i in (seq 1 (count $argv)) set -l curr_flag (__typeof_random_color_flag $argv[$i]) if test $curr_flag -eq 8; echo 1; return; else if test $curr_flag -eq 9; echo 2; return; end end echo 0; end function __fix_special_flag_formatting set -l special_flag_1 (string replace -r "(\s*)\.*" "" -- $argv) set -l special_flag_2 (string trim -- $special_flag_1) echo $special_flag_2; end function __help_message echo "" set_random_color --bright --bold --background --underline --italic --reset --command="echo ' set_random_color '"; spaced_print_separator; set_random_color --bright; set_random_color --italic --command='echo -e "\tfunction to set the terminal color to a random color"'; spaced_print_separator; set_random_color --bold --background -r --command='echo " USAGE: "'; set_random_color --light -r --command='echo -ne "set_random_color\ "'; set_random_color --bright -r --command='echo "[bold] [italic] [underline] [reset]"'; set_random_color --light -r --command='echo -e "\t\t [--bold] [--italics] [--underline] [--background] [--reset]"' echo -e " \t [--command=]" set_random_color --bright -r --command='echo -e "\t\t [--light] [--dark] [--bright] [--show-colors] [--colors]"'; set_random_color --light -r --command='echo -e "\t\t [-0] [-1] [-2] [-3] [-4] [-5] [-6] [-7] [-8]"'; echo ""; echo ""; set_random_color --light --italic; echo "set_random_color bold --> set terminal text color to bold"; echo "set_random_color -r --> equivalent to set_color_normal" echo "set_random_color -0 --> set terminal text background color"; spaced_print_separator; set_random_color --bold --background -r --command='echo " ARGUMENTS: "' set_random_color --italic; echo -e "\tbackground - random color will be shown behind the text" echo -e "\tbold - random color will be bold" echo -e "\titalic - random color will be italic" echo -e "\tunderline - random color will be underline" echo -e "\treset/normal - random color will be reset" echo -e "\t (no random color)" echo -e "\tdebug - random color picked will be displayed" echo -e "\t after the function finishes" echo -e "\thelp - displays this help message" spaced_print_separator; set_random_color --bold --background -r --command='echo " FLAGS: "' set_random_color --italic; echo -e "\t -0 -B --background - random color will be on background" echo -e "\t -1 -b --bold - random color will be bold" echo -e "\t -2 -i --italic - random color will be italic" echo -e "\t -3 -u --underline - random color will be underline" echo -e "\t -4 -r - random color will be reset" echo -e "\t --reset --normal (no random color)" echo -e "\t -5 -e --echo=\"[STR]\" - echo the string in a random color " echo -e "\t -6 -c --command=\"[CMD]\" - run command with output colored. Run" echo -e "\t with -r flag to reset after" echo -e "\t -7 -d --dump --debug - random color picked will be displayed" echo -e "\t after function finishes" echo -e "\t -8 -D --debug-all - will display all local variable " echo -e "\t --debug-all - values set in this function." spaced_print_separator; set_random_color --bold --background -r --command='echo " COLOR PREFERENCES: "' set_random_color --italic; echo -e "\t --light - possible random colors will be lighter" echo -e "\t --bright - possible random colors will be brighter" echo -e "\t (br version, i.e. brblue)" echo -e "\t --dark - possible random colors will be darker" echo -e "\t --colors - display all possible colors available" echo -e "\t --show-colors in fish shell" spaced_print_separator; set_random_color --background --reset --command="echo ' SEE ALSO: '"; set_random_color --italic; echo -e "\t• set_color_normal.fish" echo -e "\t• ~/.config/fish/completions/" echo -e "\t• ~/.config/fish/functions/" spaced_print_separator; set_random_color --bold --background --reset --bright --command='echo " set_random_color.fish is located at: "'; echo "" set_random_color --bold --light --italic --command='echo -e "\t ~/.config/fish/functions/set_random_color.fish"' echo "" echo "" end function __check_color_prefs_flag set -l found_flag 0; for i in (seq 1 (count $argv)) set -l curr_flag (__typeof_random_color_flag $argv[$i]) if test $curr_flag -eq 10; #bright #echo brred brgreen bryellow brblue brmagenta brcyan brwhite echo $random_color_array_bright return; else if test $curr_flag -eq 11; #light #echo white brcyan brmagenta brblue brgreen brwhite brred echo $random_color_array_light return; else if test $curr_flag -eq 12; #dark #echo bryellow brblack brblue brgreen black yellow blue echo $random_color_array_dark; return; else if test $curr_flag -eq 13; # error echo $random_color_array_error; return; end end #echo red green yellow blue magenta cyan white; echo $random_color_array_normal; end function __has_show_colors_flag set -l sz (count $argv) set -l found_flag_1 0 set -l found_flag_2 0 if test $sz -eq 0 return 0 end for i in (seq 1 $sz) set -l curr_arg (args_regex_helper $argv[$i]); if test "$curr_arg" = "colors";or test "$curr_arg" = "show-colors"; set found_flag_1 1; else if test "$curr_arg" = "bright"; set found_flag_2 1; else if test "$curr_arg" = "light"; set found_flag_2 2; else if test "$curr_arg" = "dark"; set found_flag_2 3; else if test "$curr_arg" = "error"; set found_flag_2 4; end end echo $found_flag_1 echo $found_flag_2 end function __print_colors set -l change_color_cmd (echo "set_color --print-colors"); spaced_print_separator; #set_random_color -B -u -b -r --echo="ALL:" set_color --print-colors; spaced_print_separator; set -l colors_arr (string split " " -- $random_color_array_normal) set -l colors_string (echo "normal") if test $argv -eq 1 set colors_string (echo "bright") set colors_arr (string split " " -- $random_color_array_bright) else if test $argv -eq 2 set colors_string (echo "light") set colors_arr (string split " " -- $random_color_array_light ) else if test $argv -eq 3 set colors_string (echo "dark") set colors_arr (string split " " -- $random_color_array_dark) else if test $argv -eq 4 set colors_string (echo "error") set colors_arr (string split " " -- $random_color_array_error) else set colors_string (echo "normal") set colors_arr (string split " " -- $random_color_array_normal) end set -l colors_string (string upper "$colors_string colors") set_random_color -b -u -r --echo="$colors_string" | print_char_rainbow --inverse echo "" set -l curr_num 1; for current in $colors_arr set -l curr_idx (__get_16_color_index $current $curr_num); set_color "#000" --background $current --bold; echo -n "$curr_idx:"; set_color_normal; set_color "#000" --background $current ; echo -n " $current"; set_color_normal; echo ""; if test $curr_num -lt (count $colors_arr) echo ""; end set curr_num (math $curr_num + 1) end spaced_print_separator; end function __get_16_color_index set -f num1 $argv[1] set -f num2 $argv[2] set -f colors_arr (string split " " -- $colors_16_array) set -l index 0 for color in $colors_arr; if test "$color" = "$num1"; echo $index;and return; end; set index (math $index + 1); end; echo $num2; and return end function __debug_dump_all -S print_spaced_separator; echo " ALL_DEBUG_INFO " | print_chars_rainbow; print_spaced_separator; set_random_color --reset --echo="cmd looks like: set_color $set_color $__random_color $special_flag" echo "" set_random_color --reset --echo="flag_amount : "$flag_amount set_random_color --reset --echo="special_flag : "$special_flag echo "" set_random_color --reset --echo="cmd_is_set : "$cmd_is_set set_random_color --reset --echo="cmd_after_color_change: "$cmd_after_color_change echo "" set_random_color --reset --echo="reset_flag_is_set: "$reset_flag_is_set set_random_color --reset --echo="debug_flag_is_set: "$debug_flag_is_set echo "" set_random_color --reset --echo="__random_color: "$__random_color echo "" print_spaced_separator; set_random_color --reset --echo="function at: ~/.config/fish/functions/set_random_color.fish" echo ""; set_random_color --reset --echo="completions at: ~/.config/fish/completions/set_random_color.fish" echo ""; set_random_color --reset --echo="flags: [0-8], [b,B,i,u,n,h,c,d], [background, bold, italic, reset, normal, debug]" echo ""; set_random_color --reset --echo="flags: [0-8], [b,B,i,u,n,h,c,d], [background, bold, italic, reset, normal, debug]" print_spaced_separator; echo "notes" | print_chars_rainbow; echo ""; echo "• tab completions are enabled"; echo "• use help flag"; print_spaced_separator; end for i in $random_color_array_normal echo "$i" if test "$i" = "1" while test $i -lt 20 echo "$i" set_random_color --reset --echo="flags: [0-8], [b,B,i,u,n,h,c,d], [background, bold, italic, reset, normal, debug]" echo "• use help flag"; print_spaced_separator; end else if test "$i" = '2' while test $i -lt 20 echo "$i" set_color_normal echo "• use help flag"; print_spaced_separator; end else set_random_color end end begin; set -l upper_limit (count $argv) for i in (seq 1 $upper_limit) set -l curr_flag $argv[$i] set -l curr_flag_type (__typeof_random_color_flag $curr_flag) if test $curr_flag_type -eq 7; set -l cmd_idx (math $i) echo $argv[$cmd_idx..-1]; return; else if test $curr_flag_type -eq 5 -o $curr_flag_type -eq 6 set curr_flag (string replace -ra '"' "" -- $curr_flag) set curr_flag (string replace -ra "'" "" -- $curr_flag) set curr_flag (string replace -ra ".*=" "" -- $curr_flag) set curr_flag (string trim --left $curr_flag) if test $curr_flag_type -eq 5 ; echo 'echo -e "$curr_flag"'; else echo "$curr_flag"; end end end end; ================================================ FILE: tests/fish_files/simple/all_variable_def_types.fish ================================================ echo "hello world" | read -l a for i in (seq 1 10) echo "hello world: $i" end function hello --description "prints hello world" -a b c d --inherit-variable PATH echo "hello world: $b $c $d" echo "$argv" echo "$PATH" end set --global e "$a$b" set --universal f "$b$c" ================================================ FILE: tests/fish_files/simple/for_var.fish ================================================ # counts down in reverse for i in (seq 1 10)[-1..1] echo $i end echo $i; #i should equal 1 -> @see `man for` ================================================ FILE: tests/fish_files/simple/func_a.fish ================================================ function func_a --description "this is func_a" set -l a a a set -l a (printf "%s\n" a a a | string join '\n') printf "%s" a a a | string unescape end #switch "$argv"; case "*"; end #switch $argv; case *;end #(program # (command name: (word) argument: (double_quote_string) redirect: (file_redirect operator: (direction) destination: (word))) # (command name: (word)) #) ================================================ FILE: tests/fish_files/simple/func_abc.fish ================================================ function func_a set -l a a a end function func_b #set -l b bb bb set -U b bb end # func_c -> c function func_c set -l c ccc ccc end ================================================ FILE: tests/fish_files/simple/function_variable_def.fish ================================================ function simple_function --argument-names hello world printf "$hello $world" end ================================================ FILE: tests/fish_files/simple/global_vs_local.fish ================================================ ########## PROGRAM set --global testvar "global symbol" echo $testvar function _test set --local testvar "local symbol" echo $testvar set --global testvar "inner global symbol" end _test set testvar "global symbol" echo $testvar ================================================ FILE: tests/fish_files/simple/inner_function.fish ================================================ function outer function inner set --local a "a" set --local a "aa" set --local a "aaa" end set a "A" end function _helper set --function b "b" end ================================================ FILE: tests/fish_files/simple/is_chained_return.fish ================================================ begin; return true; and echo "chained 1st" and echo "chained 2nd"; or echo "chained 3rd"; echo "outside chained"; end; ================================================ FILE: tests/fish_files/simple/multiple_broken_scopes.fish ================================================ function multiple_broken_scopes set -l var "$argv" if test "$var" = hello echo hello or echo "bad 1" and echo "bad 2" or echo "bad 3"; return 0; else if test "$var" = world echo $var return 0 else echo a return 0 end set -l var "$argv" if test -z "$argv" if test -z 'a' return 0 else return 0 end echo "hi" return 0 else return 0 end echo "hi" end ================================================ FILE: tests/fish_files/simple/set_var.fish ================================================ set var "hello world" ================================================ FILE: tests/fish_files/simple/simple_function.fish ================================================ # prints hello world twice function simple_function printf "hello world\n" echo "hello world" end ================================================ FILE: tests/fish_files/simple/symbols.fish ================================================ set -l arg_two 'seen one time' function func_a set -l arg_one $argv[1] for i in (seq 1 10) echo "$i: $arg_one" end end set -l arg_two 'seen two times' function func_b for i in (seq 1 10) func_a $argv end end set -l arg_two 'seen three times' function func_c --argument-names arg_one for i in (seq 1 10) func_a $arg_one end end func_b $arg_two ================================================ FILE: tests/fish_files/small_file.fish ================================================ set -l hw "hello world" echo "$hw" ================================================ FILE: tests/fish_files/switch_case_test_1.fish ================================================ function foo switch "$argv[1]" case 'bar' echo 'bar' case 'baz' echo 'baz' case '*' echo 'default' end end ================================================ FILE: tests/fish_files/umask.fish ================================================ # Support the usual (i.e., bash compatible) `umask` UI. This reports or modifies the magic global # `umask` variable which is monitored by the fish process. # This table is indexed by the base umask value to be modified. Each digit represents the new umask # value when the permissions to add are applied to the base umask value. set __fish_umask_add_table 0101010 2002200 2103210 4440000 4541010 6442200 6543210 function __fish_umask_add set -l mask_digit $argv[1] set -l to_add $argv[2] set -l mask_table 0000000 if test $mask_digit -gt 0 set mask_table $__fish_umask_add_table[$mask_digit] end set -l new_vals (string split '' $mask_table) echo $new_vals[$to_add] end # This table is indexed by the base umask value to be modified. Each digit represents the new umask # value when the permissions to remove are applied to the base umask value. set __fish_umask_remove_table 1335577 3236767 3337777 5674567 5775577 7676767 7777777 function __fish_umask_remove set -l mask_digit $argv[1] set -l to_remove $argv[2] set -l mask_table 1234567 if test $mask_digit -gt 0 set mask_table $__fish_umask_remove_table[$mask_digit] end set -l new_vals (string split '' $mask_table) echo $new_vals[$to_remove] end # This returns the mask corresponding to allowing the permissions to allow. In other words it # returns the inverse of the mask passed in. set __fish_umask_set_table 6 5 4 3 2 1 0 function __fish_umask_set set -l to_set $argv[1] if test $to_set -eq 0 return 7 end echo $__fish_umask_set_table[$to_set] end # Given a umask string, possibly in symbolic mode, return an octal value with leading zeros. # This function expects to be called with a single value. function __fish_umask_parse # Test if already a valid octal mask. If so pad it with zeros and return it. # Note that umask values are always base 8 so they don't require a leading zero. if string match -qr '^0?[0-7]{1,3}$' -- $argv string sub -s -4 0000$argv return 0 end # Test if argument is a valid symbolic mask. Note that the basic pattern allows one illegal # pattern: who and perms without a mode such as "urw". We test for that below after using the # pattern to split the rights then testing for that invalid combination. set -l basic_pattern '([ugoa]*)([=+-]?)([rwx]*)' if not string match -qr "^$basic_pattern(,$basic_pattern)*\$" -- $argv printf (_ "%s: Invalid mask '%s'\n") umask $argv >&2 return 1 end # Split umask into individual digits. We erase the first one because it should always be zero. set -l res (string split '' $umask) set -e res[1] for rights in (string split , $argv) set -l match (string match -r "^$basic_pattern\$" $rights) set -l scope $match[2] set -l mode $match[3] set -l perms $match[4] if test -n "$scope" -a -z "$mode" printf (_ "%s: Invalid mask '%s'\n") umask $argv >&2 return 1 end if test -z "$scope" set scope a end if test -z "$mode" set mode = end set -l scopes_to_modify string match -q '*u*' $scope and set scopes_to_modify 1 string match -q '*g*' $scope and set -a scopes_to_modify 2 string match -q '*o*' $scope and set -a scopes_to_modify 3 string match -q '*a*' $scope and set scopes_to_modify 1 2 3 set -l val 0 if string match -q '*r*' $perms set val 4 end if string match -q '*w*' $perms set val (math $val + 2) end if string match -q '*x*' $perms set val (math $val + 1) end for j in $scopes_to_modify switch $mode case '=' set res[$j] (__fish_umask_set $val) case '+' set res[$j] (__fish_umask_add $res[$j] $val) case - set res[$j] (__fish_umask_remove $res[$j] $val) end end end echo 0$res[1]$res[2]$res[3] return 0 end function __fish_umask_print_symbolic set -l val set -l res "" set -l letter a u g o for i in 2 3 4 set res $res,$letter[$i]= set val (echo $umask|cut -c $i) if contains $val 0 1 2 3 set res {$res}r end if contains $val 0 1 4 5 set res {$res}w end if contains $val 0 2 4 6 set res {$res}x end end echo (string split -m 1 '' -- $res)[2] end function umask --description "Set default file permission mask" set -l options h/help p/as-command S/symbolic argparse -n umask $options -- $argv or return if set -q _flag_help __fish_print_help umask return 0 end switch (count $argv) case 0 set -q umask or set -g umask 113 if set -q _flag_as_command echo umask $umask else if set -q _flag_symbolic __fish_umask_print_symbolic $umask else echo $umask end case 1 if set -l parsed (__fish_umask_parse $argv) set -g umask $parsed return 0 end return 1 case '*' printf (_ '%s: Too many arguments\n') umask >&2 return 1 end end ================================================ FILE: tests/format-aligned-columns.test.ts ================================================ import { formatAlignedColumns, AlignedItem } from '../src/utils/startup'; describe('formatAlignedColumns tests', () => { describe('empty input', () => { it('should return empty string for empty array', () => { const result = formatAlignedColumns([]); expect(result).toBe(''); }); }); describe('single string', () => { it('should center a single string with default width', () => { const input = ['Hello']; const result = formatAlignedColumns(input, 20); const expected = ' Hello '; // 7 spaces + 'Hello' + 8 spaces = 20 chars total expect(result).toBe(expected); expect(result.length).toBe(20); }); it('should center a single string with custom width', () => { const input = ['Test']; const result = formatAlignedColumns(input, 10); const expected = ' Test '; // 3 spaces + 'Test' + 3 spaces = 10 chars total expect(result).toBe(expected); expect(result.length).toBe(10); }); it('should handle single string that is too long', () => { const input = ['This is a very long string that exceeds the width']; const result = formatAlignedColumns(input, 20); expect(result).toBe(input[0]); // Should not add padding for oversized content }); }); describe('two strings', () => { it('should left align first, right align second', () => { const input = ['Server Start Time:', '808.82ms']; const result = formatAlignedColumns(input, 95); expect(result).toBe('Server Start Time:' + ' '.repeat(95 - 18 - 8) + '808.82ms'); expect(result.length).toBe(95); }); it('should handle shorter width', () => { const input = ['Left', 'Right']; const result = formatAlignedColumns(input, 20); expect(result).toBe('Left' + ' '.repeat(20 - 4 - 5) + 'Right'); expect(result.length).toBe(20); }); it('should handle strings that exactly fit', () => { const input = ['Left', 'Right']; const result = formatAlignedColumns(input, 9); // 4 + 5 = 9 expect(result).toBe('LeftRight'); expect(result.length).toBe(9); }); }); describe('three strings', () => { it('should left align first, center second, right align third', () => { const input = ['Left', 'Center', 'Right']; const result = formatAlignedColumns(input, 30); // Left(4) + padding + Center(6) + padding + Right(5) = 30 // Available space: 30 - 4 - 6 - 5 = 15 // The algorithm distributes space differently than expected expect(result.length).toBe(30); expect(result.startsWith('Left')).toBe(true); expect(result.endsWith('Right')).toBe(true); expect(result.includes('Center')).toBe(true); }); it('should handle minimum padding', () => { const input = ['A', 'B', 'C']; const result = formatAlignedColumns(input, 5); // Minimum case: 3 chars + 2 spaces expect(result).toBe('A B C'); expect(result.length).toBe(5); }); }); describe('four or more strings', () => { it('should handle four strings correctly', () => { const input = ['A', 'B', 'C', 'D']; const result = formatAlignedColumns(input, 20); // A(1) + gap + B(1) + gap + C(1) + gap + D(1) = 20 // Available space: 20 - 4 = 16, divided by 3 gaps = 5.33 -> 5 + remainder 1 // So gaps will be 5, 5, 6 or similar distribution expect(result.length).toBe(20); expect(result.startsWith('A')).toBe(true); expect(result.endsWith('D')).toBe(true); expect(result.includes('B')).toBe(true); expect(result.includes('C')).toBe(true); }); it('should handle five strings correctly', () => { const input = ['First', 'Second', 'Third', 'Fourth', 'Fifth']; const result = formatAlignedColumns(input, 50); // The algorithm may not perfectly fill to the exact width due to spacing distribution // but should be within 1 character and contain all elements expect(result.length).toBeGreaterThanOrEqual(49); expect(result.length).toBeLessThanOrEqual(50); expect(result.startsWith('First')).toBe(true); expect(result.endsWith('Fifth')).toBe(true); expect(result.includes('Second')).toBe(true); expect(result.includes('Third')).toBe(true); expect(result.includes('Fourth')).toBe(true); }); }); describe('ANSI color codes', () => { it('should handle strings with ANSI color codes correctly', () => { // Simulate chalk.blue() and chalk.white() strings const input = ['\x1b[34mServer Start Time:\x1b[39m', '\x1b[37m808.82ms\x1b[39m']; const result = formatAlignedColumns(input, 95); // The function should calculate length based on cleaned strings (without ANSI) // But preserve the original ANSI codes in output expect(result).toContain('\x1b[34mServer Start Time:\x1b[39m'); expect(result).toContain('\x1b[37m808.82ms\x1b[39m'); // Check that spacing is correct (should be same as without ANSI codes) const cleanResult = result.replace(/\x1b\[[0-9;]*m/g, ''); expect(cleanResult.length).toBe(95); }); it('should center single ANSI string correctly', () => { const input = ['\x1b[34mHello\x1b[39m']; const result = formatAlignedColumns(input, 20); const cleanResult = result.replace(/\x1b\[[0-9;]*m/g, ''); // Should be centered based on "Hello" (5 chars) with full width padding const leftPadding = Math.floor((20 - 5) / 2); // 7 const rightPadding = 20 - 5 - leftPadding; // 8 expect(cleanResult).toBe(' '.repeat(leftPadding) + 'Hello' + ' '.repeat(rightPadding)); expect(cleanResult.length).toBe(20); }); }); describe('real-world use cases', () => { it('should format server startup output correctly', () => { const testCases = [ ['Server Start Time:', '808.82ms'], ['Background Analysis Time:', '1112.08ms'], ['Total Files Indexed:', '689 files'], ['Indexed paths in \'~/.config/fish\':', '1 paths'], ]; testCases.forEach(testCase => { const result = formatAlignedColumns(testCase, 95); expect(result.length).toBe(95); expect(result.startsWith(testCase.at(0)!)).toBe(true); expect(result.endsWith(testCase.at(1)!)).toBe(true); }); }); it('should format table-like output correctly', () => { const input = [' [1]', '| /home/ndonfris/.config/fish |', '689 files']; const result = formatAlignedColumns(input, 95); expect(result.length).toBe(95); expect(result.startsWith(' [1]')).toBe(true); expect(result.endsWith('689 files')).toBe(true); expect(result.includes('| /home/ndonfris/.config/fish |')).toBe(true); }); }); describe('edge cases', () => { it('should handle very small width', () => { const input = ['A', 'B']; const result = formatAlignedColumns(input, 2); expect(result).toBe('AB'); expect(result.length).toBe(2); }); it('should handle width smaller than content', () => { const input = ['Very long string', 'Another long string']; const result = formatAlignedColumns(input, 10); // Should still try to format, but won't fit in 10 chars expect(result).toContain('Very long string'); expect(result).toContain('Another long string'); }); it('should use environment COLUMNS when no width specified', () => { const originalColumns = process.env.COLUMNS; process.env.COLUMNS = '50'; const input = ['Test', 'String']; const result = formatAlignedColumns(input); expect(result.length).toBe(50); // Restore original COLUMNS if (originalColumns !== undefined) { process.env.COLUMNS = originalColumns; } else { delete process.env.COLUMNS; } }); it('should default to 95 when COLUMNS is not set', () => { const originalColumns = process.env.COLUMNS; delete process.env.COLUMNS; const input = ['Test', 'String']; const result = formatAlignedColumns(input); expect(result.length).toBe(95); // Restore original COLUMNS if (originalColumns !== undefined) { process.env.COLUMNS = originalColumns; } }); }); describe('explicit alignment', () => { it('should handle explicit left alignment', () => { const input: AlignedItem[] = [ { text: 'Left1', align: 'left' }, { text: 'Left2', align: 'left' }, { text: 'Right', align: 'right' }, ]; const result = formatAlignedColumns(input, 30); expect(result.startsWith('Left1Left2')).toBe(true); expect(result.endsWith('Right')).toBe(true); expect(result.length).toBe(30); }); it('should handle explicit center alignment', () => { const input: AlignedItem[] = [ { text: 'Left', align: 'left' }, { text: 'Center1', align: 'center' }, { text: 'Center2', align: 'center' }, { text: 'Right', align: 'right' }, ]; const result = formatAlignedColumns(input, 40); expect(result.startsWith('Left')).toBe(true); expect(result.endsWith('Right')).toBe(true); expect(result.includes('Center1')).toBe(true); expect(result.includes('Center2')).toBe(true); expect(result.length).toBe(40); }); it('should handle explicit right alignment', () => { const input: AlignedItem[] = [ { text: 'Left', align: 'left' }, { text: 'Right1', align: 'right' }, { text: 'Right2', align: 'right' }, ]; const result = formatAlignedColumns(input, 25); expect(result.startsWith('Left')).toBe(true); expect(result.endsWith('Right1Right2')).toBe(true); expect(result.length).toBe(25); }); it('should mix string and explicit alignment', () => { const input: AlignedItem[] = [ 'DefaultLeft', { text: 'ExplicitCenter', align: 'center' }, 'DefaultRight', ]; const result = formatAlignedColumns(input, 50); expect(result.startsWith('DefaultLeft')).toBe(true); expect(result.endsWith('DefaultRight')).toBe(true); expect(result.includes('ExplicitCenter')).toBe(true); expect(result.length).toBe(50); }); it('should handle ANSI codes with explicit alignment', () => { const input: AlignedItem[] = [ { text: '\x1b[34mBlueLeft\x1b[39m', align: 'left' }, { text: '\x1b[32mGreenRight\x1b[39m', align: 'right' }, ]; const result = formatAlignedColumns(input, 30); expect(result).toContain('\x1b[34mBlueLeft\x1b[39m'); expect(result).toContain('\x1b[32mGreenRight\x1b[39m'); const cleanResult = result.replace(/\x1b\[[0-9;]*m/g, ''); expect(cleanResult.length).toBe(30); }); }); describe('advanced formatting features', () => { describe('truncation', () => { it('should truncate from right for left-aligned items', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextThatShouldBeTruncated', align: 'left', maxWidth: 15, truncate: true }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('…'); expect(result.length).toBe(20); // Should truncate from right since it's left-aligned expect(result.trim().startsWith('VeryLongTextTh')).toBe(true); }); it('should truncate from left for right-aligned items', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextThatShouldBeTruncated', align: 'right', maxWidth: 15, truncate: true }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('…'); expect(result.length).toBe(20); // Should truncate from left since it's right-aligned expect(result.trim().endsWith('dBeTruncated')).toBe(true); }); it('should truncate from center for center-aligned items', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextThatShouldBeTruncated', align: 'center', maxWidth: 15, truncate: true }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('…'); expect(result.length).toBe(20); // Should truncate from middle since it's center-aligned const cleaned = result.trim(); expect(cleaned).toMatch(/^Very.*….*ated$/); }); it('should use custom truncate indicator', () => { const input: AlignedItem[] = [ { text: 'VeryLongText', maxWidth: 8, truncate: true, truncateIndicator: '...' }, ]; const result = formatAlignedColumns(input, 15); expect(result).toContain('...'); expect(result).not.toContain('…'); }); it('should account for padding in truncation', () => { const input: AlignedItem[] = [ { text: 'VeryLongText', maxWidth: 10, truncate: true, padLeft: '[', padRight: ']', }, ]; const result = formatAlignedColumns(input, 15); expect(result).toContain('['); expect(result).toContain(']'); expect(result).toContain('…'); // Should account for brackets in truncation calculation }); it('should use explicit truncateBehavior "left"', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextForTesting', align: 'left', // Would normally truncate right, but we override maxWidth: 15, truncate: true, truncateBehavior: 'left', }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('…'); // Should truncate from left despite left alignment const cleaned = result.trim(); expect(cleaned).toMatch(/^….*Testing$/); }); it('should use explicit truncateBehavior "right"', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextForTesting', align: 'right', // Would normally truncate left, but we override maxWidth: 15, truncate: true, truncateBehavior: 'right', }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('…'); // Should truncate from right despite right alignment const cleaned = result.trim(); expect(cleaned).toMatch(/^VeryLongTextF.*…$/); }); it('should use explicit truncateBehavior "middle"', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextForTesting', align: 'left', // Would normally truncate right, but we override maxWidth: 15, truncate: true, truncateBehavior: 'middle', }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('…'); // Should truncate from middle despite left alignment const cleaned = result.trim(); expect(cleaned).toMatch(/^Very.*….*ting$/); }); it('should maintain backward compatibility with alignment-based truncation', () => { const input: (AlignedItem & { align: 'left' | 'right' | 'center'; })[] = [ { text: 'VeryLongTextForTesting', align: 'left', maxWidth: 15, truncate: true }, { text: 'VeryLongTextForTesting', align: 'right', maxWidth: 15, truncate: true }, { text: 'VeryLongTextForTesting', align: 'center', maxWidth: 15, truncate: true }, ]; // Test each alignment's default truncation behavior input.forEach((item /*index*/) => { const result = formatAlignedColumns([item], 20); const cleaned = result.trim(); // console.log({ // alignment: item.align, // result, // cleaned, // index // }) if (item.align === 'left') { // Left alignment should truncate from right by default expect(cleaned).toMatch(/^VeryLongTextF.*…$/); } else if (item.align === 'right') { // Right alignment should truncate from left by default expect(cleaned).toMatch(/^….*Testing$/); } else if (item.align === 'center') { // Center alignment should truncate from middle by default expect(cleaned).toMatch(/^Very.*….*ting$/); } }); }); }); describe('padding', () => { it('should apply padLeft and padRight', () => { const input: AlignedItem[] = [ { text: 'Content', padLeft: '[', padRight: ']' }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('[Content]'); expect(result.length).toBe(20); }); it('should apply pad to both sides', () => { const input: AlignedItem[] = [ { text: 'Content', pad: '|' }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('|Content|'); expect(result.length).toBe(20); }); it('should prioritize pad over padLeft/padRight', () => { const input: AlignedItem[] = [ { text: 'Content', pad: '*', padLeft: '[', padRight: ']' }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('*Content*'); expect(result).not.toContain('['); expect(result).not.toContain(']'); }); it('should apply padding even when truncated', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextThatWillBeTruncated', maxWidth: 10, truncate: true, pad: '|', }, ]; const result = formatAlignedColumns(input, 15); expect(result).toContain('|'); expect(result).toContain('…'); // Should have padding even after truncation expect(result.trim()).toMatch(/^\|.*….*\|$/); }); }); describe('text transformation', () => { it('should transform text to uppercase', () => { const input: AlignedItem[] = [ { text: 'hello world', transform: 'uppercase' }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('HELLO WORLD'); }); it('should transform text to lowercase', () => { const input: AlignedItem[] = [ { text: 'HELLO WORLD', transform: 'lowercase' }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('hello world'); }); it('should capitalize text', () => { const input: AlignedItem[] = [ { text: 'hello WORLD', transform: 'capitalize' }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('Hello world'); }); }); describe('width constraints', () => { it('should enforce minimum width', () => { const input: AlignedItem[] = [ { text: 'Short', minWidth: 15, align: 'left' }, ]; const result = formatAlignedColumns(input, 20); const cleaned = result.replace(/\x1b\[[0-9;]*m/g, ''); expect(cleaned.indexOf('Short')).toBe(0); // The item itself should be at least 15 characters, but the full result might be 20 expect(result.length).toBe(20); // Full width expect(result.startsWith('Short')).toBe(true); // The item should be padded to at least minWidth const shortIndex = result.indexOf('Short'); const nextNonSpace = result.slice(shortIndex + 5).search(/\S/); const itemLength = nextNonSpace === -1 ? result.length - shortIndex : shortIndex + 5 + nextNonSpace - shortIndex; expect(itemLength).toBeGreaterThanOrEqual(15); }); it('should enforce fixed width', () => { const input: AlignedItem[] = [ { text: 'Content', fixedWidth: 12, align: 'center' }, ]; const result = formatAlignedColumns(input, 20); // The item itself should be exactly 12 characters when processed individually // but within the full result, it gets integrated into the overall alignment expect(result.length).toBe(20); // Full width should be 20 expect(result.includes('Content')).toBe(true); // The content should be centered within a 12-character width before overall alignment const contentIndex = result.indexOf('Content'); expect(contentIndex).toBeGreaterThanOrEqual(0); }); it('should handle fixed width with center alignment', () => { const input: AlignedItem[] = [ { text: 'Hi', fixedWidth: 10, align: 'center' }, ]; const result = formatAlignedColumns(input, 15); expect(result.length).toBe(15); // Should center 'Hi' within the 10-character fixed width }); }); describe('complex combinations', () => { it('should handle truncation + padding + transformation', () => { const input: AlignedItem[] = [ { text: 'verylongtext', maxWidth: 10, transform: 'uppercase', pad: '|', truncate: true, align: 'left', }, ]; const result = formatAlignedColumns(input, 15); expect(result).toContain('|'); expect(result).toContain('VERY'); // Should be uppercase expect(result).toContain('…'); }); it('should handle all features together', () => { const input: AlignedItem[] = [ { text: 'left item', align: 'left', padLeft: '[', padRight: ']', transform: 'uppercase', minWidth: 15, }, { text: 'very long center text that will be truncated', align: 'center', maxWidth: 25, truncate: true, truncateBehavior: 'left', pad: '|', }, { text: 'right', align: 'right', transform: 'capitalize', fixedWidth: 8, }, ]; const result = formatAlignedColumns(input, 50); expect(result).toContain('[LEFT ITEM]'); expect(result).toContain('|'); expect(result).toContain('…'); expect(result).toContain('Right'); expect(result.length).toBe(50); }); it('should handle truncateBehavior with other features', () => { const input: AlignedItem[] = [ { text: 'VeryLongTextThatNeedsTruncation', align: 'right', // Would normally truncate left maxWidth: 12, truncate: true, truncateBehavior: 'right', // Override to truncate right transform: 'uppercase', pad: '*', }, ]; const result = formatAlignedColumns(input, 20); expect(result).toContain('*'); expect(result).toContain('…'); expect(result).toContain('VERYLONGT'); // Should be uppercase and truncated // Should truncate from right despite right alignment const cleaned = result.replace(/\*/g, '').trim(); expect(cleaned).toMatch(/^VERYLONGT.*…$/); }); }); }); }); ================================================ FILE: tests/formatting.test.ts ================================================ import { formatDocumentContent, formatDocumentWithIndentComments } from '../src/formatting'; import { setLogger } from './helpers'; import { TestWorkspace, TestFile } from './test-workspace-utils'; setLogger(); /** * For logging a formatted output string, and using it as a snapshot later in a test * * outputs a string for the: * `expect(result).toBe([ HERE ].join('\n').trim())` */ function helperOutputFormattedString(input: string) { const arr = input.split('\n'); arr.forEach((line: string, index: number) => { if (index === 0) { console.log(`[\'${line}\',`); } else if (index === arr.length - 1) { console.log(`\'${line}\'].join(\'\\n\')`); } else if (index < arr.length - 1) { console.log(`\'${line}\',`); } }); } describe('formatting tests', () => { it('formatting no change', async () => { const input = 'set -gx PATH ~/.config/fish/'; const result = (await formatDocumentContent(input)).trim(); expect(result).toBe(input); }); it('formatting function', async () => { const input: string = [ 'function a', 'if set -q _some_value', 'echo "_some_value is set: $_some_value"', 'end', 'end', ].join('\n'); const result = await formatDocumentContent(input); expect(result).toBe([ 'function a', ' if set -q _some_value', ' echo "_some_value is set: $_some_value"', ' end', 'end', '', ].join('\n')); }); /** * the formatter always formats to spaces of size 4 */ it('formatting if statement', async () => { const input: string = [ 'if test $status -eq 0', ' echo yes', 'else if test $status -eq 1', 'echo no', 'else', ' echo maybe', 'end', ].join('\n').trim(); const result = (await formatDocumentContent(input)).trim(); expect(result).toBe([ '', 'if test $status -eq 0', ' echo yes', 'else if test $status -eq 1', ' echo no', 'else', ' echo maybe', 'end', ].join('\n').trim()); }); it('formatting switch case', async () => { const input: string = [ 'switch "$argv"', 'case \'y\' \'Y\' \'\'', ' return 0', 'case \'n\' \'N\'', ' return 1', 'case \'*\'', ' return 2', 'end', ].join('\n').trim(); const result = (await formatDocumentContent(input)).trim(); // helperOutputFormattedString(result) expect(result).toBe([ 'switch "$argv"', ' case y Y \'\'', ' return 0', ' case n N', ' return 1', ' case \'*\'', ' return 2', 'end', ].join('\n').trim()); }); /** * Does not add 'end' tokens * && * NO error when unbalanced 'end' tokens */ it('for loop single line', async () => { const input = 'for i in (seq 1 10); echo $i; '; const result = (await formatDocumentContent(input)).trim(); expect(result).toBe([ 'for i in (seq 1 10)', 'echo $i', ].join('\n').trim()); }); /** * formatter removes ';' */ it('for loop multi line', async () => { const input = [ 'for i in (seq 1 10);', 'echo $i; ', 'end', ].join('\n').trim(); console.log(); // fish_indent now breaks lines with ';' into '\n\n' const result = (await formatDocumentContent(input)).trim(); console.log({ 'for loop multi line': '`' + result + '`', input: '`' + input + '`', }); expect(result).toBeTruthy(); expect(result).toBe([ 'for i in (seq 1 10)', '', ' echo $i', '', 'end', ].join('\n').trim()); // expect(result).toBe([ // 'for i in (seq 1 10)', // ' echo $i', // 'end', // ].join('\n').trim()); }); }); describe('@fish_indent toggle formatting tests', () => { describe('basic', () => { const workspace = TestWorkspace.createSingle(` echo "should be formatted" # @fish_indent: off echo "should not be formatted" echo "still not formatted" # @fish_indent: on echo "should be formatted again"`).initialize(); it('should skip formatting when @fish_indent: off is used', async () => { const doc = workspace.focusedDocument!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // First formatted line should be formatted (fish_indent removes leading empty lines) expect(lines[0]).toBe('echo "should be formatted"'); // @fish_indent: off comment should be preserved expect(lines[1]).toBe('# @fish_indent: off'); // Lines within off/on block should remain unformatted expect(lines[2]).toBe('echo "should not be formatted"'); // Not indented expect(lines[3]).toBe(' echo "still not formatted"'); // Original indentation preserved // @fish_indent: on comment should be preserved with original indentation level (no spaces in this case) expect(lines[4]).toBe('# @fish_indent: on '); // Last line should be formatted (no change in this case) expect(lines[5]).toBe('echo "should be formatted again"'); }); }); describe('no comments', () => { const workspace = TestWorkspace.createSingle(`function test echo "hello" if test $status -eq 0 echo "success" end end`).initialize(); it('should format entire document when no @fish_indent comments present', async () => { const doc = workspace.focusedDocument!; const result = await formatDocumentWithIndentComments(doc); expect(result).toContain(' echo hello'); // Should be indented expect(result).toContain(' echo success'); // Should be double indented }); }); describe('disabled via comment', () => { const workspace = TestWorkspace.createSingle(` # @fish_indent: off function test echo "unformatted" end # @fish_indent: on function test2 echo "formatted" end`).initialize(); it('should handle document starting with @fish_indent: off', async () => { const doc = workspace.focusedDocument!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // From debug output, we can see: // 'function test' (formatted) // 'echo "unformatted"' (unformatted) // 'end' (formatted) // 'function test2' (formatted) // ' echo formatted' (formatted with indentation) // 'end' (formatted) // BUT wait, this is wrong - let me look at the test case more carefully. // The test starts with @fish_indent: off, so the structure should be different // I need to find the actual line by content instead const unformattedLineIndex = lines.findIndex(line => line.includes('echo "unformatted"')); const formattedLineIndex = lines.findIndex(line => line.includes('echo formatted') && !line.includes('"unformatted"')); // Unformatted section should not be indented expect(lines[unformattedLineIndex]).toBe('echo "unformatted"'); // Formatted section should be indented expect(lines[formattedLineIndex]).toBe(' echo formatted'); }); }); describe('EOF comment', () => { const workspace = TestWorkspace.createSingle(`function test echo "formatted" end # @fish_indent: off function test2 echo "unformatted" end`).initialize(); it('should handle document ending with @fish_indent: off', async () => { const doc = workspace.focusedDocument!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // EOF test structure: document has formatted content first, then @fish_indent: off // So the structure should be: // function test (formatted) // echo "formatted" (formatted with indentation) // end (formatted) // # @fish_indent: off (preserved comment) // function test2 (unformatted) // echo "unformatted" (unformatted) // end (unformatted) const formattedLineIndex = lines.findIndex(line => line.includes('echo formatted') && !line.includes('"unformatted"')); const unformattedLineIndex = lines.findIndex(line => line.includes('echo "unformatted"')); const offCommentIndex = lines.findIndex(line => line.includes('# @fish_indent: off')); // Formatted section should be indented expect(lines[formattedLineIndex]).toBe(' echo formatted'); // @fish_indent: off comment should be preserved expect(lines[offCommentIndex]).toBe('# @fish_indent: off'); // Unformatted section should not be indented expect(lines[unformattedLineIndex]).toBe('echo "unformatted"'); }); }); describe('multiple on/off pairs', () => { const workspace = TestWorkspace.create().addFiles( TestFile.script('pair_1.fish', `echo "line 0 - format" echo "line 1 - format" # @fish_indent: off echo "line 3 - no format" echo "line 4 - no format" # @fish_indent: on function test echo "line 7 - format" end # @fish_indent: off echo "line 10 - no format" # @fish_indent: on echo "line 12 - format"`), TestFile.script('pair_2.fish', `# @fish_indent: off echo "line 0 - no format" echo "line 1 - no format" # @fish_indent: on echo "line 3 - format" # @fish_indent: off echo "line 5 - no format" echo "line 6 - no format" # @fish_indent: on`), TestFile.script('no_pair.fish', `function test echo "formatted" end # @fish_indent function test2 echo "also formatted" end`), TestFile.function('header_comment.fish', ` function header_comment # @fish_indent: off echo "should not be formatted"; echo "should also not be formatted" # @fish_indent: on echo "should be formatted" end `), TestFile.script('trailing.fish', `function foo # @fish_indent: off fish_color_autosuggestion brblack fish_color_cancel -r # @fish_indent: on end echo a; echo b`), TestFile.script('semicolon_split.fish', `# Test semicolon commands being split by fish_indent echo a; echo b; echo c # @fish_indent: off echo x; echo y; echo z # @fish_indent: on echo 1; echo 2; echo 3`), TestFile.script('complex_structure.fish', `function complex_func # @fish_indent: off set -l var1 "unformatted value" set -l var2 "another unformatted" echo $var1; echo $var2; echo "inline commands" # @fish_indent: on if test $status -eq 0 echo "this should be formatted" set -l formatted_var "formatted value" end # @fish_indent: off switch $argv[1] case "a" "b" "c" return 0 case "*" return 1 end # @fish_indent: on end`), TestFile.script('nested_blocks.fish', `if test -f ~/.config/fish/config.fish # @fish_indent: off source ~/.config/fish/config.fish set -gx EDITOR vim # @fish_indent: on for file in *.fish echo "Processing $file" # @fish_indent: off chmod +x $file; chown user:group $file # @fish_indent: on source $file end end`), TestFile.script('empty_blocks.fish', `echo "before empty block" # @fish_indent: off # @fish_indent: on echo "after empty block" # @fish_indent: off # @fish_indent: on echo "after whitespace-only block"`), ).initialize(); it('should handle multiple @fish_indent off/on pairs', async () => { const doc = workspace.getDocument('pair_1.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // Now with preserved comments, the structure should be: // echo "line 0 - format" (formatted) // echo "line 1 - format" (formatted) // # @fish_indent: off (preserved comment) // echo "line 3 - no format" (unformatted) // echo "line 4 - no format" (unformatted) // # @fish_indent: on (preserved comment) // function test (formatted) // ... rest follows // First formatted section expect(lines[0]).toBe('echo "line 0 - format"'); expect(lines[1]).toBe('echo "line 1 - format"'); // @fish_indent: off comment expect(lines[2]).toBe('# @fish_indent: off'); // First unformatted section expect(lines[3]).toBe('echo "line 3 - no format"'); expect(lines[4]).toBe('echo "line 4 - no format"'); // @fish_indent: on comment expect(lines[5]).toBe('# @fish_indent: on'); // Second formatted section (function) expect(lines[6]).toBe('function test'); expect(lines[7]).toBe(' echo "line 7 - format"'); // Should be indented inside function expect(lines[8]).toBe('end'); // @fish_indent: off comment expect(lines[9]).toBe('# @fish_indent: off'); // Second unformatted section expect(lines[10]).toBe('echo "line 10 - no format"'); // @fish_indent: on comment expect(lines[11]).toBe('# @fish_indent: on'); // Final formatted section expect(lines[12]).toBe('echo "line 12 - format"'); }); it('should handle @fish_indent without explicit on/off value', async () => { const doc = workspace.getDocument('no_pair.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // Both sections should be formatted since @fish_indent defaults to "on" expect(lines[1]).toBe(' echo formatted'); // The second function's echo should also be formatted expect(lines[5]).toBe(' echo "also formatted"'); }); it('should handle leading empty lines before first @fish_indent comment', async () => { const doc = workspace.getDocument('header_comment.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); console.log(lines); console.log({ header_comment: '`' + result + '`', lines_length: lines.length, }); // First unformatted section (accounting for leading empty lines) // expect(lines[5]).toBe('echo "should not be formatted"; echo "should also not be formatted"'); // Should not be indented // // // Formatted section // expect(lines[7]).toBe(' echo "should be formatted"'); // Should be indented }); it('should handle trailing whitespace after @fish_indent: on', async () => { const doc = workspace.getDocument('trailing.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); console.log('Trailing whitespace test result:'); console.log(lines); // The 'end' should be properly indented after @fish_indent: on // Find the line with 'end' and verify it's indented // const endLineIndex = lines.findIndex(line => line.trim() === 'end'); // expect(endLineIndex).toBeGreaterThan(-1); // expect(lines[endLineIndex]).toBe('end'); // Should be properly indented to match function }); it('should handle semicolon commands being split by fish_indent', async () => { const doc = workspace.getDocument('semicolon_split.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // First semicolon command should be split and formatted expect(lines).toContain('echo a'); expect(lines).toContain('echo b'); expect(lines).toContain('echo c'); // Unformatted section should keep semicolons expect(result).toContain('echo x; echo y; echo z'); // Last semicolon command should be split and formatted again expect(lines).toContain('echo 1'); expect(lines).toContain('echo 2'); expect(lines).toContain('echo 3'); }); it('should handle complex function structure with mixed formatting', async () => { const doc = workspace.getDocument('complex_structure.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // Function declaration should be formatted expect(lines[0]).toBe('function complex_func'); // Unformatted variable declarations should preserve original spacing expect(result).toContain('set -l var1 "unformatted value"'); expect(result).toContain('set -l var2 "another unformatted"'); expect(result).toContain('echo $var1; echo $var2; echo "inline commands"'); // Formatted if block should be properly indented expect(result).toContain(' if test $status -eq 0'); expect(result).toContain(' echo "this should be formatted"'); expect(result).toContain(' set -l formatted_var "formatted value"'); expect(result).toContain(' end'); // Unformatted switch should preserve original indentation expect(result).toContain('case "a" "b" "c"'); expect(result).toContain('return 0'); }); it('should handle nested blocks with alternating formatting', async () => { const doc = workspace.getDocument('nested_blocks.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // Outer if should be formatted expect(lines[0]).toBe('if test -f ~/.config/fish/config.fish'); // Unformatted section should preserve original structure expect(result).toContain('source ~/.config/fish/config.fish'); expect(result).toContain('set -gx EDITOR vim'); // For loop should be formatted expect(result).toContain(' for file in *.fish'); expect(result).toContain(' echo "Processing $file"'); // Nested unformatted section should preserve semicolons expect(result).toContain('chmod +x $file; chown user:group $file'); // Source command should be formatted (indented) expect(result).toContain(' source $file'); }); it('should handle empty and whitespace-only unformatted blocks', async () => { const doc = workspace.getDocument('empty_blocks.fish')!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // Should have content before and after empty blocks expect(result).toContain('echo "before empty block"'); expect(result).toContain('echo "after empty block"'); expect(result).toContain('echo "after whitespace-only block"'); // Should not have excessive empty lines expect(result).not.toMatch(/\n{4,}/); // No more than 3 consecutive newlines }); }); describe('inline comment support', () => { const workspace = TestWorkspace.createSingle(`function test echo foo # @fish_indent: off echo "unformatted line" echo "another unformatted" # @fish_indent: on echo "formatted again" end`).initialize(); it('should handle inline @fish_indent comments', async () => { const doc = workspace.focusedDocument!; const result = await formatDocumentWithIndentComments(doc); const lines = result.split('\n'); // Function declaration should be formatted expect(lines[0]).toBe('function test'); // Line with inline comment should have the code formatted expect(lines[1]).toBe(' echo foo'); // @fish_indent: off comment should be on its own line with proper indentation expect(lines[2]).toBe(' # @fish_indent: off'); // Unformatted content should be preserved expect(result).toContain('echo "unformatted line"'); expect(result).toContain(' echo "another unformatted"'); // @fish_indent: on comment should be properly indented (no spaces in this case) expect(result).toContain('# @fish_indent: on'); // Final line should be formatted expect(result).toContain(' echo "formatted again"'); }); }); describe('edge cases with structural changes', () => { const workspace = TestWorkspace.create().addFiles( TestFile.script('multiline_commands.fish', `# Commands that span multiple lines set -l long_variable_name "this is a very long value that might wrap" \\ "and continues on the next line" # @fish_indent: off set -l unformatted_long "this should stay" \\ "exactly as written" # @fish_indent: on set -l another_long "this should be formatted" \\ "and properly indented"`), TestFile.script('mixed_quotes.fish', `echo 'single quotes' echo "double quotes" echo \`command substitution\` # @fish_indent: off echo 'unformatted single' echo "unformatted double" echo \`unformatted command\` # @fish_indent: on echo 'formatted single' echo "formatted double"`), TestFile.script('comment_preservation.fish', `# This is a regular comment echo "formatted command" # inline comment # @fish_indent: off # This comment should be preserved echo "unformatted" # with inline comment # Indented comment should stay indented # @fish_indent: on # This comment should be formatted echo "formatted again" # inline comment`), ).initialize(); it('should preserve multiline command structure in unformatted blocks', async () => { const doc = workspace.getDocument('multiline_commands.fish')!; const result = await formatDocumentWithIndentComments(doc); // Formatted multiline commands should be properly indented expect(result).toContain('set -l long_variable_name "this is a very long value that might wrap"'); // Unformatted multiline should preserve exact structure expect(result).toContain('set -l unformatted_long "this should stay" \\'); expect(result).toContain('"exactly as written"'); // Last multiline should be formatted again expect(result).toContain('set -l another_long "this should be formatted"'); }); it('should handle different quote types correctly', async () => { const doc = workspace.getDocument('mixed_quotes.fish')!; const result = await formatDocumentWithIndentComments(doc); // All formatted quotes should be preserved expect(result).toContain("echo 'single quotes'"); expect(result).toContain('echo "double quotes"'); expect(result).toContain('echo `command substitution`'); // Unformatted quotes should be preserved exactly expect(result).toContain("echo 'unformatted single'"); expect(result).toContain('echo "unformatted double"'); expect(result).toContain('echo `unformatted command`'); // Final formatted quotes should be preserved expect(result).toContain("echo 'formatted single'"); expect(result).toContain('echo "formatted double"'); }); it('should preserve regular comments and @fish_indent comments', async () => { const doc = workspace.getDocument('comment_preservation.fish')!; const result = await formatDocumentWithIndentComments(doc); // Regular comments should be preserved expect(result).toContain('# This is a regular comment'); expect(result).toContain('# inline comment'); expect(result).toContain('# This comment should be preserved'); expect(result).toContain('# with inline comment'); expect(result).toContain('# Indented comment should stay indented'); expect(result).toContain('# This comment should be formatted'); // @fish_indent comments should now be preserved expect(result).toContain('# @fish_indent: off'); expect(result).toContain('# @fish_indent: on'); // Commands should be present expect(result).toContain('echo "formatted command"'); expect(result).toContain('echo "unformatted"'); expect(result).toContain('echo "formatted again"'); }); }); }); ================================================ FILE: tests/helpers.ts ================================================ import { glob } from 'fast-glob'; import fs, { readFileSync } from 'fs'; import { homedir } from 'os'; import * as path from 'path'; import { resolve } from 'path'; import { DocumentSymbol, Location, Range, SymbolKind, TextDocumentItem } from 'vscode-languageserver'; import * as LSP from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import * as Parser from 'web-tree-sitter'; import { Point, SyntaxNode, Tree } from 'web-tree-sitter'; import { vi } from 'vitest'; import { analyzer, Analyzer } from '../src/analyze'; import { documents, LspDocument } from '../src/document'; import { initializeParser } from '../src/parser'; import { FishSymbol, processNestedTree } from '../src/parsing/symbol'; import { env } from '../src/utils/env-manager'; import { flattenNested } from '../src/utils/flatten'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { pathToUri } from '../src/utils/translation'; import { getChildNodes, getNamedChildNodes } from '../src/utils/tree-sitter'; import { Workspace } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { testOpenDocument } from './document-test-helpers'; import { logger } from '../src/logger'; /** * Sets up mock for the startup module. * Call this BEFORE importing FishServer or any module that imports from startup. * * @example * ```typescript * import { setupStartupMock } from './helpers'; * * // At the top of your test file, before other imports * setupStartupMock(); * * // Now import FishServer * import FishServer from '../src/server'; * ``` */ export function setupStartupMock() { vi.mock('../src/utils/startup', () => ({ connection: { listen: vi.fn(), onInitialize: vi.fn(), onInitialized: vi.fn(), onShutdown: vi.fn(), onExit: vi.fn(), onDidOpenTextDocument: vi.fn(), onDidChangeTextDocument: vi.fn(), onDidCloseTextDocument: vi.fn(), onDidSaveTextDocument: vi.fn(), onWillSaveTextDocument: vi.fn(), onWillSaveTextDocumentWaitUntil: vi.fn(), onCompletion: vi.fn(), onCompletionResolve: vi.fn(), onDocumentSymbol: vi.fn(), onWorkspaceSymbol: vi.fn(), onWorkspaceSymbolResolve: vi.fn(), onDefinition: vi.fn(), onImplementation: vi.fn(), onReferences: vi.fn(), onHover: vi.fn(), onRenameRequest: vi.fn(), onPrepareRename: vi.fn(), onDocumentFormatting: vi.fn(), onDocumentRangeFormatting: vi.fn(), onDocumentOnTypeFormatting: vi.fn(), onCodeAction: vi.fn(), onCodeActionResolve: vi.fn(), onCodeLens: vi.fn(), onCodeLensResolve: vi.fn(), onFoldingRanges: vi.fn(), onSelectionRanges: vi.fn(), onDocumentHighlight: vi.fn(), onDocumentLinks: vi.fn(), onDocumentLinkResolve: vi.fn(), onDocumentColor: vi.fn(), onColorPresentation: vi.fn(), onTypeDefinition: vi.fn(), onDeclaration: vi.fn(), onSignatureHelp: vi.fn(), onExecuteCommand: vi.fn(), languages: { inlayHint: { on: vi.fn(), resolve: vi.fn(), }, semanticTokens: { on: vi.fn(), onDelta: vi.fn(), onRange: vi.fn(), }, onLinkedEditingRange: vi.fn(), }, onRequest: vi.fn(), onNotification: vi.fn(), sendRequest: vi.fn(), sendNotification: vi.fn(), sendDiagnostics: vi.fn(), sendProgress: vi.fn(), onProgress: vi.fn(), console: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn(), connection: {} as any, }, window: { createWorkDoneProgress: vi.fn().mockResolvedValue({ begin: vi.fn(), report: vi.fn(), done: vi.fn(), }), showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), showInformationMessage: vi.fn(), showDocument: vi.fn(), }, workspace: { onDidChangeWorkspaceFolders: vi.fn(), onDidCreateFiles: vi.fn(), onDidRenameFiles: vi.fn(), onDidDeleteFiles: vi.fn(), onWillCreateFiles: vi.fn(), onWillRenameFiles: vi.fn(), onWillDeleteFiles: vi.fn(), getConfiguration: vi.fn(), getWorkspaceFolders: vi.fn(), applyEdit: vi.fn(), }, tracer: { log: vi.fn(), connection: {} as any, }, telemetry: { logEvent: vi.fn(), connection: {} as any, }, client: { register: vi.fn(), connection: {} as any, }, dispose: vi.fn(), onDispose: vi.fn(), } as unknown as LSP.Connection, createBrowserConnection: vi.fn().mockImplementation(() => ({ listen: vi.fn(), onInitialize: vi.fn(), onInitialized: vi.fn(), onShutdown: vi.fn(), onExit: vi.fn(), onDidOpenTextDocument: vi.fn(), onDidChangeTextDocument: vi.fn(), onDidCloseTextDocument: vi.fn(), onDidSaveTextDocument: vi.fn(), onWillSaveTextDocument: vi.fn(), onWillSaveTextDocumentWaitUntil: vi.fn(), onCompletion: vi.fn(), onCompletionResolve: vi.fn(), onDocumentSymbol: vi.fn(), onWorkspaceSymbol: vi.fn(), onWorkspaceSymbolResolve: vi.fn(), onDefinition: vi.fn(), onImplementation: vi.fn(), onReferences: vi.fn(), onHover: vi.fn(), onRenameRequest: vi.fn(), onPrepareRename: vi.fn(), onDocumentFormatting: vi.fn(), onDocumentRangeFormatting: vi.fn(), onDocumentOnTypeFormatting: vi.fn(), onCodeAction: vi.fn(), onCodeActionResolve: vi.fn(), onCodeLens: vi.fn(), onCodeLensResolve: vi.fn(), onFoldingRanges: vi.fn(), onSelectionRanges: vi.fn(), onDocumentHighlight: vi.fn(), onDocumentLinks: vi.fn(), onDocumentLinkResolve: vi.fn(), onDocumentColor: vi.fn(), onColorPresentation: vi.fn(), onTypeDefinition: vi.fn(), onDeclaration: vi.fn(), onSignatureHelp: vi.fn(), onExecuteCommand: vi.fn(), languages: { inlayHint: { on: vi.fn(), resolve: vi.fn(), }, semanticTokens: { on: vi.fn(), onDelta: vi.fn(), onRange: vi.fn(), }, onLinkedEditingRange: vi.fn(), }, onRequest: vi.fn(), onNotification: vi.fn(), sendRequest: vi.fn(), sendNotification: vi.fn(), sendDiagnostics: vi.fn(), sendProgress: vi.fn(), onProgress: vi.fn(), console: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn(), connection: {} as any, }, window: { createWorkDoneProgress: vi.fn().mockResolvedValue({ begin: vi.fn(), report: vi.fn(), done: vi.fn(), }), showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), showInformationMessage: vi.fn(), showDocument: vi.fn(), }, workspace: { onDidChangeWorkspaceFolders: vi.fn(), onDidCreateFiles: vi.fn(), onDidRenameFiles: vi.fn(), onDidDeleteFiles: vi.fn(), onWillCreateFiles: vi.fn(), onWillRenameFiles: vi.fn(), onWillDeleteFiles: vi.fn(), getConfiguration: vi.fn(), getWorkspaceFolders: vi.fn(), applyEdit: vi.fn(), }, tracer: { log: vi.fn(), connection: {} as any, }, telemetry: { logEvent: vi.fn(), connection: {} as any, }, client: { register: vi.fn(), connection: {} as any, }, dispose: vi.fn(), onDispose: vi.fn(), } as unknown as LSP.Connection)), setExternalConnection: vi.fn(), })); } export const fail = () => { return (msg?: string) => { expect(true).toBe(false); return null; }; }; export function setLogger( beforeCallback: () => Promise = async () => { }, afterCallback: () => Promise = async () => { }, ) { const jestConsole = console; beforeEach(async () => { global.console = require('console'); await beforeCallback(); }); afterEach(async () => { global.console = jestConsole; await afterCallback(); }); } /** * Create a mock LSP connection that can be reused across tests. * This provides all the necessary LSP.ServerCapabilities methods mocked with vi.fn() * * @returns A mocked LSP.Connection object with all handlers and capabilities */ export function createMockConnection(): LSP.Connection { return { listen: vi.fn(), onInitialize: vi.fn(), onInitialized: vi.fn(), onShutdown: vi.fn(), onExit: vi.fn(), onDidOpenTextDocument: vi.fn(), onDidChangeTextDocument: vi.fn(), onDidCloseTextDocument: vi.fn(), onDidSaveTextDocument: vi.fn(), onWillSaveTextDocument: vi.fn(), onWillSaveTextDocumentWaitUntil: vi.fn(), onCompletion: vi.fn(), onCompletionResolve: vi.fn(), onDocumentSymbol: vi.fn(), onWorkspaceSymbol: vi.fn(), onWorkspaceSymbolResolve: vi.fn(), onDefinition: vi.fn(), onImplementation: vi.fn(), onReferences: vi.fn(), onHover: vi.fn(), onRenameRequest: vi.fn(), onPrepareRename: vi.fn(), onDocumentFormatting: vi.fn(), onDocumentRangeFormatting: vi.fn(), onDocumentOnTypeFormatting: vi.fn(), onCodeAction: vi.fn(), onCodeActionResolve: vi.fn(), onCodeLens: vi.fn(), onCodeLensResolve: vi.fn(), onFoldingRanges: vi.fn(), onSelectionRanges: vi.fn(), onDocumentHighlight: vi.fn(), onDocumentLinks: vi.fn(), onDocumentLinkResolve: vi.fn(), onDocumentColor: vi.fn(), onColorPresentation: vi.fn(), onTypeDefinition: vi.fn(), onDeclaration: vi.fn(), onSignatureHelp: vi.fn(), onExecuteCommand: vi.fn(), languages: { inlayHint: { on: vi.fn(), resolve: vi.fn(), }, semanticTokens: { on: vi.fn(), onDelta: vi.fn(), onRange: vi.fn(), }, onLinkedEditingRange: vi.fn(), }, onRequest: vi.fn(), onNotification: vi.fn(), sendRequest: vi.fn(), sendNotification: vi.fn(), sendDiagnostics: vi.fn(), sendProgress: vi.fn(), onProgress: vi.fn(), console: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn(), connection: {} as any, }, window: { createWorkDoneProgress: vi.fn().mockResolvedValue({ begin: vi.fn(), report: vi.fn(), done: vi.fn(), }), showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), showInformationMessage: vi.fn(), showDocument: vi.fn(), }, workspace: { onDidChangeWorkspaceFolders: vi.fn(), onDidCreateFiles: vi.fn(), onDidRenameFiles: vi.fn(), onDidDeleteFiles: vi.fn(), onWillCreateFiles: vi.fn(), onWillRenameFiles: vi.fn(), onWillDeleteFiles: vi.fn(), getConfiguration: vi.fn(), getWorkspaceFolders: vi.fn(), applyEdit: vi.fn(), }, tracer: { log: vi.fn(), connection: {} as any, }, telemetry: { logEvent: vi.fn(), connection: {} as any, }, client: { register: vi.fn(), connection: {} as any, }, dispose: vi.fn(), onDispose: vi.fn(), } as unknown as LSP.Connection; } /** * Helper function to get references to mocked initialization functions * Use this AFTER you've set up vi.mock() for the modules in your test file. * * @example * ```typescript * import { getMockedInitializationFunctions } from './helpers'; * * // In your test (after vi.mock calls) * const { initializeDocumentationCache } = await import('../src/utils/documentation-cache'); * * await FishServer.create(mockConnection, mockParams); * * // Verify initialization was called * expect(initializeDocumentationCache).toHaveBeenCalled(); * ``` */ export async function getMockedInitializationFunctions() { const docCache = await import('../src/utils/documentation-cache'); const workspace = await import('../src/utils/workspace'); const completionCache = await import('../src/utils/completion/startup-cache'); const pager = await import('../src/utils/completion/pager'); const processEnv = await import('../src/utils/process-env'); return { initializeDocumentationCache: docCache.initializeDocumentationCache, initializeDefaultFishWorkspaces: workspace.initializeDefaultFishWorkspaces, getWorkspacePathsFromInitializationParams: workspace.getWorkspacePathsFromInitializationParams, CompletionItemMapInitialize: completionCache.CompletionItemMap.initialize, initializeCompletionPager: pager.initializeCompletionPager, setupProcessEnvExecFile: processEnv.setupProcessEnvExecFile, }; } /** * @param {string} fname - relative path to file, in tests folder * @param {boolean} inAutoloadPath - simulate the doc uri being in ~/.config/fish/functions/*.fish * @returns {LspDocument} - lsp document (from '../src/document.ts') */ export function resolveLspDocumentForHelperTestFile(fname: string, inAutoloadPath: boolean = true): LspDocument { // check which path type is fname -----------> absolute path | relative path const filepath = fname.startsWith(homedir()) ? resolve(fname) : resolve(__dirname, fname); const file = readFileSync(filepath, 'utf8'); const filename = inAutoloadPath ? `file://${homedir()}/.config/fish/functions/${fname.split('/').at(-1)}` : `file://${filepath}`; const doc = TextDocumentItem.create(filename, 'fish', 0, file); return new LspDocument(doc); } export async function resolveAbsPath(fname: string): Promise { const file = readFileSync(resolve(fname), 'utf8'); return file.split('\n'); } export function positionStr(pos: Point) { return `{ row: ${pos.row.toString()}, column: ${pos.column.toString()} }`; } export async function parseFile(fname: string): Promise { const text = await resolveAbsPath(fname); const parser = await initializeParser(); const tree = parser.parse(text.join('\n')); return tree; } export function createFakeUriPath(path: string): string { if (path.startsWith('/')) { return `file://${path}`; } return `file://${homedir()}/.config/fish/${path}`; } export type TestLspDocument = { path: string; text: string | string[]; }; export function createTestWorkspace( analyzer: Analyzer, ...docs: TestLspDocument[] ) { const result: LspDocument[] = []; for (const doc of docs) { const newDoc = createFakeLspDocument(doc.path, ...Array.isArray(doc.text) ? doc.text : [doc.text]); analyzer.analyze(newDoc); result.push(newDoc); } return result; } type FakeLspDocumentType = { uri: string; languageId?: string; version?: number; text: string; }; export class FakeLspDocument extends LspDocument { constructor(input: FakeLspDocumentType = { languageId: 'fish', version: 0, uri: 'file://fake/path.fish', text: '' }) { super(createFakeLspDocument(input.uri, input.text).asTextDocumentItem()); } static from(uri: string, ...text: string[]): FakeLspDocument { return new FakeLspDocument({ uri, text: text.join('\n') }); } } export function createFakeLspDocument(name: string, ...text: string[]): LspDocument { logger.setSilent(true); const uri = createFakeUriPath(name); const doc = LspDocument.createTextDocumentItem(uri, text.join('\n')); // get the current workspace, if it exists, otherwise create a test workspace const workspace: Workspace = workspaceManager?.findContainingWorkspace(uri) || Workspace.syncCreateFromUri(uri)!; // Add the uri to the workspace if it isn't already there and it should be // This is to ensure that test workspaces group similar files together if (workspace.shouldContain(uri)) { workspace.add(uri); } // add the workspace to the `workspaces` array if it doesn't already exist // if (!workspaceManager.hasContainingWorkspace(uri)) { // } workspaceManager.add(workspace); testOpenDocument(doc); // update currentWorkspace.current with the new workspace // workspaceManager.setCurrent(workspace) return doc; } export function setupTestCallback(parser: Parser) { return function setupTestDocument(name: string, ...text: string[]): { doc: LspDocument; input: string; tree: Tree; root: SyntaxNode; } { const input = text.join('\n'); const doc = createFakeLspDocument(name, input); const tree = parser.parse(input); const root = tree.rootNode; return { doc, tree, root, input }; }; } export function getAllTypesOfNestedArrays(doc: LspDocument, root: SyntaxNode) { const allNodes: SyntaxNode[] = getChildNodes(root); const allNamedNodes: SyntaxNode[] = getNamedChildNodes(root); const nodes: SyntaxNode[] = flattenNested(root); const flatNodes: SyntaxNode[] = flattenNested(root); const symbols: FishSymbol[] = processNestedTree(doc, root); const flatSymbols: FishSymbol[] = flattenNested(...symbols); return { allNodes, allNamedNodes, nodes, flatNodes, symbols, flatSymbols, }; } export type PrintClientTreeOpts = { log: boolean; }; /** * Will print the client tree of document definition symbols */ export function printClientTree( opts: PrintClientTreeOpts = { log: true }, ...symbols: FishSymbol[] | DocumentSymbol[] ): string[] { const result: string[] = []; function logAtLevel(indent = '', ...remainingSymbols: FishSymbol[] | DocumentSymbol[]): string[] { const newResult: string[] = []; remainingSymbols.forEach(n => { let kind = ''; if (DocumentSymbol.is(n)) { kind = n.kind === SymbolKind.Function ? 'FUNCTION' : n.kind === SymbolKind.Variable ? 'VARIABLE' : n.kind === SymbolKind.Event ? 'EVENT' : n.kind.toString(); } if (FishSymbol.is(n)) { kind = n.fishKind.toUpperCase(); } if (opts.log && FishSymbol.is(n)) { console.log(`${indent}${n.name} --- ${kind} --- ${n.scope.scopeTag} --- ${n.scope.scopeNode.firstNamedChild?.text}`); } else if (opts.log && DocumentSymbol.is(n)) { console.log(`${indent}${n.name} --- ${kind} --- ${n.range.start.line}:${n.range.start.character} - ${n.range.end.line}:${n.range.end.character}`); } newResult.push(`${indent}${n.name}`); const children = n.children || []; newResult.push(...logAtLevel(indent + ' ', ...children)); }); return newResult; } result.push(...logAtLevel('', ...symbols)); return result; } export function locationAsString(loc: Location): string[] { return [ LspDocument.testUri(loc.uri), ...[loc.range.start.line, loc.range.start.character, loc.range.end.line, loc.range.end.character].map(s => s.toString()), ]; } export function rangeAsString(range: Range): string { const result = [ ...[range.start.line, range.start.character, range.end.line, range.end.character].map(s => s.toString()), ]; return `[${result.join(', ')}]`; } export function fakeDocumentTrimUri(doc: LspDocument): string { if (['conf.d', 'functions', 'completions'].includes(doc.getAutoloadType())) { return [doc.getAutoloadType(), doc.getFileName()].join('/'); } if ('config' === doc.getAutoloadType()) { return doc.getFileName(); } return doc.getFileName(); } export function printLocations(locations: Location[], opts: { verbose?: boolean; showText?: boolean; showLineText?: boolean; showIndex?: boolean; rangeVerbose?: boolean; } = { verbose: false, showText: false, showLineText: false, rangeVerbose: false, showIndex: false, }): void { locations.forEach((loc, idx) => { const doc = analyzer.started ? analyzer.getDocument(loc.uri) : undefined; const obj = { uri: LspDocument.testUri(loc.uri), range: rangeAsString(loc.range), startPos: opts.verbose || opts.rangeVerbose ? loc.range.start : undefined, endPos: opts.verbose || opts.rangeVerbose ? loc.range.end : undefined, text: opts.verbose || opts.showText ? analyzer.getTextAtLocation(loc) : undefined, lineText: opts.verbose || opts.showLineText ? doc?.getLine(loc.range) : undefined, index: opts.verbose || opts.showIndex ? idx.toString() : undefined, }; const cleanObj = Object.fromEntries( Object.entries(obj).filter(([, value]) => value !== undefined), ); console.log(cleanObj); }); } /** * Call this function in a `beforeEach()`/`beforeAll()` block of a test suite, and * it will allow you to use fish-lsp's autoloaded fish variables in your tests. * ___ * Example: * ___ * ```typescript * import { fishLocations, FishLocations } from './helpers'; * let locations: FishLocations; * describe('My test suite', () => { * beforeAll(async () => { * locations = await fishLocations(); * }) * it('does something', () => { * expect(locations.paths.fish_config.dir).toBe('/home/user/.config/fish'); * }); * }) * ``` * ___ * @returns {Promise} a promise that resolves to an object with uris and paths to common fish locations */ export async function fishLocations(): Promise { await setupProcessEnvExecFile(); const _fish_config_dir = env.getAsArray('__fish_config_dir').at(0)?.toString() || ''; const _fish_config_config = path.join(_fish_config_dir, 'config.fish'); const _fish_config_functions = path.join(_fish_config_dir, 'functions'); const _fish_config_completions = path.join(_fish_config_dir, 'completions'); const _fish_config_confd = path.join(_fish_config_dir, 'conf.d'); const _fish_data_dir = env.getAsArray('__fish_data_dir').at(0)?.toString() || ''; const _fish_data_config = path.join(_fish_data_dir, 'config.fish'); const _fish_data_functions = path.join(_fish_data_dir, 'functions'); const _fish_data_completions = path.join(_fish_data_dir, 'completions'); const _fish_data_confd = path.join(_fish_data_dir, 'conf.d'); const _fish_test_workspace_dir = path.join(__dirname, 'workspaces', 'workspace_1', 'fish').toString(); const _fish_test_workspace_config = path.join(_fish_test_workspace_dir, 'config.fish'); const _fish_test_workspace_functions = path.join(_fish_test_workspace_dir, 'functions'); const _fish_test_workspace_completions = path.join(_fish_test_workspace_dir, 'completions'); const _fish_test_workspace_confd = path.join(_fish_test_workspace_dir, 'conf.d'); const _tmp_dir = path.join('tmp', 'fish_lsp_workspace'); const _tmp_config = path.join(_tmp_dir, 'config.fish'); const _tmp_functions = path.join(_tmp_dir, 'functions'); const _tmp_completions = path.join(_tmp_dir, 'completions'); const _tmp_confd = path.join(_tmp_dir, 'conf.d'); function createFishLocationGroup(dir: string, config: string, functions: string, completions: string, confd: string) { return { dir, config, functions, completions, confd }; } function createFishLocationGroupFromUri(dir: string, config: string, functions: string, completions: string, confd: string) { return { dir: createFakeUriPath(dir), config: createFakeUriPath(config), functions: createFakeUriPath(functions), completions: createFakeUriPath(completions), confd: createFakeUriPath(confd) }; } return { paths: { fish_config: createFishLocationGroup(_fish_config_dir, _fish_config_config, _fish_config_functions, _fish_config_completions, _fish_config_confd), fish_data: createFishLocationGroup(_fish_data_dir, _fish_data_config, _fish_data_functions, _fish_data_completions, _fish_data_confd), test_workspace: createFishLocationGroup(_fish_test_workspace_dir, _fish_test_workspace_config, _fish_test_workspace_functions, _fish_test_workspace_completions, _fish_test_workspace_confd), tmp: createFishLocationGroup(_tmp_dir, _tmp_config, _tmp_functions, _tmp_completions, _tmp_confd), }, uris: { fish_config: createFishLocationGroupFromUri(_fish_config_dir, _fish_config_config, _fish_config_functions, _fish_config_completions, _fish_config_confd), fish_data: createFishLocationGroupFromUri(_fish_data_dir, _fish_data_config, _fish_data_functions, _fish_data_completions, _fish_data_confd), test_workspace: createFishLocationGroupFromUri(_fish_test_workspace_dir, _fish_test_workspace_config, _fish_test_workspace_functions, _fish_test_workspace_completions, _fish_test_workspace_confd), tmp: createFishLocationGroupFromUri(_tmp_dir, _tmp_config, _tmp_functions, _tmp_completions, _tmp_confd), }, } as const; } export type FishLocations = { /** * The paths to the fish directories/files */ paths: { /** * __fish_config_dir */ fish_config: { dir: string; config: string; functions: string; completions: string; confd: string; }; /** * __fish_data_dir */ fish_data: { dir: string; config: string; functions: string; completions: string; confd: string; }; /** * test_workspace */ test_workspace: { dir: string; config: string; functions: string; completions: string; confd: string; }; /** * /tmp/fish_lsp_workspace */ tmp: { dir: string; config: string; functions: string; completions: string; confd: string; }; }; /** * The URIs to the fish directories/files */ uris: { /** * __fish_config_dir */ fish_config: { dir: string; config: string; functions: string; completions: string; confd: string; }; /** * __fish_data_dir */ fish_data: { dir: string; config: string; functions: string; completions: string; confd: string; }; /** * test_workspace */ test_workspace: { dir: string; config: string; functions: string; completions: string; confd: string; }; /** * /tmp/fish_lsp_workspace */ tmp: { dir: string; config: string; functions: string; completions: string; confd: string; }; }; }; type FishTestWorkspaceLocation = { uri: string; path: string; documents: LspDocument[]; }; export function getAllFilesInDir(dir: string): { uri: string; path: string; functions: FishTestWorkspaceLocation; completions: FishTestWorkspaceLocation; confd: FishTestWorkspaceLocation; config: FishTestWorkspaceLocation; allDocuments: LspDocument[]; allFiles: string[]; allUris: string[]; } { const resultObj = { uri: pathToUri(dir), path: dir, functions: { uri: pathToUri(path.join(dir, 'functions')), path: path.join(dir, 'functions'), documents: [] as LspDocument[], }, completions: { uri: pathToUri(path.join(dir, 'completions')), path: path.join(dir, 'completions'), documents: [] as LspDocument[], }, confd: { uri: pathToUri(path.join(dir, 'conf.d')), path: path.join(dir, 'conf.d'), documents: [] as LspDocument[], }, config: { uri: pathToUri(path.join(dir, 'config.fish')), path: path.join(dir, 'config.fish'), documents: [] as LspDocument[], }, allDocuments: [] as LspDocument[], allFiles: [] as string[], allUris: [] as string[], }; glob.sync('**/*.fish', { cwd: dir, absolute: true }).forEach(file => { const fileUri = pathToUri(file); const doc = LspDocument.createFromUri(fileUri); if (dir.endsWith('functions')) { resultObj.functions.documents.push(doc); } else if (dir.endsWith('completions')) { resultObj.completions.documents.push(doc); } else if (dir.endsWith('conf.d')) { resultObj.confd.documents.push(doc); } else if (file.endsWith('config.fish')) { resultObj.config.documents.push(doc); } resultObj.allDocuments.push(doc); resultObj.allFiles.push(file); resultObj.allUris.push(fileUri); }); return resultObj; } export namespace TestWorkspaces { export const workspace1Path = path.join(__dirname, 'workspaces', 'workspace_1', 'fish'); // export const workspace2Path = path.join(__dirname, 'workspaces', 'workspace_2'); export const workspace3Path = path.join(__dirname, 'workspaces', 'workspace_3', 'fish'); export const workspace1 = getAllFilesInDir(workspace1Path); // export const workspace2 = getAllFilesInDir(workspace2Path); export const workspace3 = getAllFilesInDir(workspace3Path); export function truncatedUri(doc: LspDocument, opts: { maxLength: number; showWorkspace: boolean; } = { maxLength: 80, showWorkspace: !doc.uri.includes('/fish/'), }): string { const endSearchStr = opts?.showWorkspace ? '/workspace_' : '/fish/'; const start = doc.uri.slice(0, URI.parse(doc.uri).scheme.length + 3); const middle = '...'; const end = doc.uri.slice(doc.uri.lastIndexOf(endSearchStr)); let result = [ start, middle, end, ].join(''); if (opts?.maxLength < result.length) { result = [ start, end, ].join('').toString(); } return result; } } ================================================ FILE: tests/inline-variable.test.ts ================================================ import { Analyzer, analyzer } from '../src/analyze'; import { isInlineVariableAssignment, parseInlineVariableAssignment, hasInlineVariables, processInlineVariables, findAllInlineVariables } from '../src/parsing/inline-variable'; import { LspDocument } from '../src/document'; import Parser from 'web-tree-sitter'; describe('Inline Variable Parsing', () => { let parser: Parser; let testDocument: LspDocument; beforeAll(async () => { await Analyzer.initialize(); }); beforeEach(() => { testDocument = LspDocument.createTextDocumentItem('file:///test.fish', ''); parser = analyzer.parser; }); it('should detect inline variable assignments', () => { const code = 'NVIM_APPNAME=nvim-lua nvim'; const tree = analyzer.parser.parse(code); const commandNode = tree.rootNode.firstNamedChild!; expect(hasInlineVariables(commandNode)).toBe(true); }); it('should parse variable name and value correctly', () => { const code = 'DEBUG=1 npm test'; const tree = analyzer.parser.parse(code); const commandNode = tree.rootNode.firstNamedChild!; const firstArg = commandNode.firstNamedChild!; expect(isInlineVariableAssignment(firstArg)).toBe(true); const parsed = parseInlineVariableAssignment(firstArg); expect(parsed).toEqual({ name: 'DEBUG', value: '1', }); }); it('should extract FishSymbols for inline variables', () => { const code = 'PATH=/usr/local/bin:$PATH EDITOR=nvim command arg1 arg2'; const tree = analyzer.parser.parse(code); testDocument = LspDocument.createTextDocumentItem('file:///test.fish', code); const commandNode = tree.rootNode.firstNamedChild!; const symbols = processInlineVariables(testDocument, commandNode); expect(symbols).toHaveLength(2); expect(symbols[0]?.name).toBe('PATH'); expect(symbols[1]?.name).toBe('EDITOR'); expect(symbols[0]?.fishKind).toBe('INLINE_VARIABLE'); }); it('should find all inline variables in a document', () => { const code = ` DEBUG=1 npm test NVIM_APPNAME=nvim-lua nvim normal_command without variables HTTP_PROXY=proxy:8080 curl example.com `; const tree = analyzer.parser.parse(code); testDocument = LspDocument.createTextDocumentItem('file:///test.fish', code); const symbols = findAllInlineVariables(testDocument, tree.rootNode); expect(symbols).toHaveLength(3); expect(symbols.map(s => s.name)).toEqual(['DEBUG', 'NVIM_APPNAME', 'HTTP_PROXY']); }); it('should not detect regular variable assignments as inline', () => { const code = 'set DEBUG 1'; const tree = analyzer.parser.parse(code); const commandNode = tree.rootNode.firstNamedChild!; expect(hasInlineVariables(commandNode)).toBe(false); }); it('should handle empty values', () => { const code = 'EMPTY= command'; const tree = analyzer.parser.parse(code); const commandNode = tree.rootNode.firstNamedChild!; const firstArg = commandNode.firstNamedChild!; const parsed = parseInlineVariableAssignment(firstArg); expect(parsed).toEqual({ name: 'EMPTY', value: '', }); }); }); ================================================ FILE: tests/install_scripts/generate_largest_fish_files.fish ================================================ #!/usr/bin/fish # moves large test files into test_data for fl in (du /usr/share/fish/functions/*.fish | sort -n -r | head -n 10 | cut -d \t -f2); set -l fl_relative_path (echo "$fl" | string split '/' -r --max 1)[2] echo -e "copying \"$fl_relative_path\" to \"test_data/fish_files/$fl_relative_path\"" cp "$fl" "./fish_files/$fl_relative_path" end ================================================ FILE: tests/interactive-buffers.test.ts ================================================ import { TestWorkspace, TestFile } from './test-workspace-utils'; import { analyzer, Analyzer } from '../src/analyze'; import { LspDocument } from '../src/document'; import { setLogger } from './helpers'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { FishSymbol } from '../src/parsing/symbol'; import { getRenames } from '../src/renames'; // test suite for `funced`, `edit_commandline_buffer`, and any other interactive // command buffers that may be added in the future. // // These buffers have special behavior, such as not // allowing renames of variables or functions defined within them, and // this suite will ensure that this behavior is correctly implemented and maintained. // // Tests may include a variety of features for these buffers, such as: // - confirming correct rename request behavior // - references and definitions across all documents // - diagnostics and code-actions related to the special behavior of these buffers // - ensuring that the special behavior does not affect other documents or buffers // - any other relevant features or edge cases that may arise from the unique nature of these interactive command buffers. describe('Interactive Command Buffers (funced, edit_commandline_buffer, ...)', () => { const tw = TestWorkspace.create() .addFiles( TestFile.custom('/tmp/fish.HBob9J/command-line.fish', [ 'for i in (seq 1 10)', ' # make sure i does not allow renames in ~/.config/fish/*', 'end', ].join('\n'), ), TestFile.custom('/tmp/fish.HBob9J/funced.fish', ['function foo', ' echo "foo"', 'end', ].join('\n'), ), TestFile.custom('/home/user/.config/fish/config.fish', [ '', '# original i', 'set -q i && echo $i', '', '# original functions', 'function foo', ' echo "original foo"', 'end', '', 'function bar', ' echo "original bar"', 'end', '', '# This is a comment', 'function baz', ' echo "original baz"', 'end', ].join('\n'), ), TestFile.custom('/home/user/.config/fish/functions/foo_foo.fish', [ '# original functions', 'function foo_foo', ' foo', ' echo "original foo"', ' set -q i && echo $i', 'end', ].join('\n'), ), ).initialize(); let cliDocument: LspDocument; let funcedDocument: LspDocument; let configDocument: LspDocument; let fooFooDocument: LspDocument; beforeAll(async () => { await Analyzer.initialize(); await setupProcessEnvExecFile(); setLogger(); cliDocument = tw.find('/tmp/fish.HBob9J/command-line.fish')!; funcedDocument = tw.find('/tmp/fish.HBob9J/funced.fish')!; configDocument = tw.find('/home/user/.config/fish/config.fish')!; fooFooDocument = tw.find('/home/user/.config/fish/functions/foo_foo.fish')!; }); it('confirm docs', () => { expect(cliDocument).toBeDefined(); expect(funcedDocument).toBeDefined(); expect(configDocument).toBeDefined(); expect(fooFooDocument).toBeDefined(); }); it('should not allow renames in command-line buffers', () => { const { document, flatSymbols } = analyzer.analyze(cliDocument); const forSym: FishSymbol = flatSymbols.find(sym => sym.name === 'i')!; const renames = getRenames(document, forSym.toLocation().range.start, 'ii'); expect(renames).toHaveLength(1); expect(renames[0]?.range).toBe(forSym.toLocation().range); }); it('should allow renames in funced buffers', () => { const { document, flatSymbols } = analyzer.analyze(funcedDocument); const funcSym: FishSymbol = flatSymbols.find(sym => sym.name === 'foo')!; const renames = getRenames(document, funcSym.toLocation().range.start, 'foo_renamed'); expect(renames).toHaveLength(1); expect(renames[0]?.range).toBe(funcSym.toLocation().range); }); }); ================================================ FILE: tests/issue-140-complete-command-quoting.test.ts ================================================ /** * Regression tests for issue #140: * https://github.com/ndonfris/fish-lsp/issues/140 * * Problem: * `complete -c 'mas' -f` in `completions/mas.fish` produces a false-positive * diagnostic 4005 ("Autoloaded completion missing command name") because the * validator compared the raw node text (e.g. `'mas'`, `"mas"`, `\mas`) directly * against the filename stem (`mas`), without stripping quotes or escape sequences. * * All of the following representations of `mas` must be recognized as equivalent * when used as the `-c` argument in a `complete` command: * * mas → unquoted word * 'mas' → single-quoted string * "mas" → double-quoted string * \mas → backslash-escaped first character * \ma\s → backslash-escaped first and last characters * ma\s → backslash-escaped last character */ import Parser from 'web-tree-sitter'; import { initializeParser } from '../src/parser'; import { Analyzer, analyzer } from '../src/analyze'; import { createFakeLspDocument, createMockConnection } from './helpers'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { ErrorCodes } from '../src/diagnostics/error-codes'; import { config } from '../src/config'; import { logger } from '../src/logger'; import { connection } from '../src/utils/startup'; import FishServer from '../src/server'; import { InitializeParams } from 'vscode-languageserver'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; // --------------------------------------------------------------------------- // Test data // --------------------------------------------------------------------------- const COMMAND_NAME = 'mas'; /** * Every fish-shell representation of the bare string `mas`. * Each entry drives both the unit tests (node shape) and the integration tests * (no false-positive diagnostic 4005). * * Node type notes (from tree-sitter-fish grammar): * - Pure unquoted words → `word` * - Single-quoted strings → `single_quote_string` * - Double-quoted strings → `double_quote_string` * - Words that mix escape sequences with regular chars → `concatenation` * e.g. `\mas` = escape_sequence(`\m`) + word(`as`) = concatenation */ const MAS_REPRESENTATIONS: { input: string; description: string; nodeType: string; }[] = [ { input: 'mas', description: 'unquoted word', nodeType: 'word' }, { input: "'mas'", description: 'single-quoted string', nodeType: 'single_quote_string' }, { input: '"mas"', description: 'double-quoted string', nodeType: 'double_quote_string' }, { input: '\\mas', description: 'backslash before first character', nodeType: 'concatenation' }, { input: '\\ma\\s', description: 'backslash before first and last chars', nodeType: 'concatenation' }, { input: 'ma\\s', description: 'backslash before last character', nodeType: 'concatenation' }, ]; // --------------------------------------------------------------------------- // Suite setup // --------------------------------------------------------------------------- describe('issue #140 – complete -c with quoted/escaped command names', () => { let parser: Parser; beforeAll(async () => { parser = await initializeParser(); await Analyzer.initialize(); createMockConnection(); await FishServer.create(connection, {} as InitializeParams); logger.setSilent(); await setupProcessEnvExecFile(); }); beforeEach(() => { // Suppress diagnostics that are unrelated to the issue under test so they // don't mask the signal we care about. config.fish_lsp_diagnostic_disable_error_codes = [ ErrorCodes.unknownCommand, // 7001 – `mas` is not a real command ErrorCodes.requireAutloadedFunctionHasDescription, // 4008 – description not under test ]; }); afterEach(() => { config.fish_lsp_diagnostic_disable_error_codes = []; }); // ------------------------------------------------------------------------- // Unit tests: tree-sitter node shape // ------------------------------------------------------------------------- describe('unit: tree-sitter parses each representation correctly', () => { /** * For each input, verify: * 1. The source parses without a tree-sitter error. * 2. The argument node after `-c` carries the expected raw text. * 3. The node type matches what fish-lsp's `isString()` would evaluate. */ for (const { input, description, nodeType } of MAS_REPRESENTATIONS) { it(`"${input}" (${description}) – raw text and node type`, () => { const source = `complete -c ${input} -f`; const tree = parser.parse(source); // Locate the `complete` command node. const commandNode = tree.rootNode.children.find((n: Parser.SyntaxNode) => n.type === 'command'); expect(commandNode).toBeDefined(); // Walk the children to find the argument that immediately follows `-c`. const children = commandNode!.children; const dashCIdx = children.findIndex((c: Parser.SyntaxNode) => c.text === '-c'); expect(dashCIdx).toBeGreaterThan(-1); // The argument node is the next sibling after `-c`. const argNode = children[dashCIdx + 1]; expect(argNode).toBeDefined(); // Raw text must match exactly what was written in the source. expect(argNode!.text).toBe(input); // Node type determines whether `isString()` returns true/false. expect(argNode!.type).toBe(nodeType); }); } }); // ------------------------------------------------------------------------- // Integration tests: no false-positive diagnostic 4005 // ------------------------------------------------------------------------- describe('integration: no false-positive 4005 for completions/mas.fish', () => { /** * The golden rule: any valid fish representation of `mas` used as * `complete -c …` inside `completions/mas.fish` must NOT produce * diagnostic 4005 ("Autoloaded completion missing command name"). */ for (const { input, description } of MAS_REPRESENTATIONS) { it(`complete -c ${input} -f → no 4005 (${description})`, async () => { const doc = createFakeLspDocument( `completions/${COMMAND_NAME}.fish`, `complete -c ${input} -f`, ); const cached = analyzer.analyze(doc); const diagnostics = await getDiagnosticsAsync(cached.root!, doc); const falsePositives = diagnostics.filter( d => d.code === ErrorCodes.autoloadedCompletionMissingCommandName, ); expect(falsePositives).toHaveLength(0); }); } it('multiple representations in one file → no 4005 for any of them', async () => { const lines = MAS_REPRESENTATIONS.map(({ input }) => `complete -c ${input} -f`); const doc = createFakeLspDocument( `completions/${COMMAND_NAME}.fish`, ...lines, ); const cached = analyzer.analyze(doc); const diagnostics = await getDiagnosticsAsync(cached.root!, doc); const falsePositives = diagnostics.filter( d => d.code === ErrorCodes.autoloadedCompletionMissingCommandName, ); expect(falsePositives).toHaveLength(0); }); // Sanity-check: a genuinely mismatched command name MUST still fire 4005. it('complete -c other_command -f → DOES produce 4005 (negative control)', async () => { const doc = createFakeLspDocument( `completions/${COMMAND_NAME}.fish`, 'complete -c other_command -f', ); const cached = analyzer.analyze(doc); const diagnostics = await getDiagnosticsAsync(cached.root!, doc); const code4005 = diagnostics.filter( d => d.code === ErrorCodes.autoloadedCompletionMissingCommandName, ); expect(code4005.length).toBeGreaterThan(0); }); }); }); ================================================ FILE: tests/logger.test.ts ================================================ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; import * as fc from 'fast-check'; import { Logger, logger, createServerLogger, IConsole, LOG_LEVELS, LogLevel, DEFAULT_LOG_LEVEL, now } from '../src/logger'; import { setLogger } from './helpers'; // Mock fs module completely - no real file operations needed vi.mock('fs', () => ({ default: { writeFileSync: vi.fn(), readFileSync: vi.fn(), appendFileSync: vi.fn(), existsSync: vi.fn(), mkdtempSync: vi.fn().mockReturnValue('/tmp/mock-temp-dir'), rmSync: vi.fn(), unlinkSync: vi.fn(), readdirSync: vi.fn().mockReturnValue([]), }, writeFileSync: vi.fn(), readFileSync: vi.fn(), appendFileSync: vi.fn(), existsSync: vi.fn(), mkdtempSync: vi.fn().mockReturnValue('/tmp/mock-temp-dir'), rmSync: vi.fn(), unlinkSync: vi.fn(), readdirSync: vi.fn().mockReturnValue([]), })); // Mock the config module vi.mock('../src/config', () => ({ config: { fish_lsp_log_level: 'debug', }, })); describe('Logger', () => { let testLogger: Logger; let mockConsole: IConsole; let stdoutSpy: any; let stderrSpy: any; let mockFs: any; beforeEach(() => { testLogger = new Logger(); mockConsole = { log: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; // Setup filesystem mocks mockFs = { writeFileSync: vi.mocked(fs.writeFileSync), readFileSync: vi.mocked(fs.readFileSync), appendFileSync: vi.mocked(fs.appendFileSync), existsSync: vi.mocked(fs.existsSync), }; // Reset all mocks vi.clearAllMocks(); mockFs.existsSync.mockReturnValue(false); // Default to file not existing // Mock stdout/stderr stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); }); afterEach(() => { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); vi.clearAllMocks(); }); describe('Basic Configuration', () => { it('should create logger with default values', () => { expect(new Logger().logFilePath).toBe(''); expect(new Logger().isStarted()).toBe(false); expect(new Logger().isSilent()).toBe(false); expect(new Logger().isClearing()).toBe(true); }); it('should support method chaining', () => { fc.assert(fc.property( fc.string({ minLength: 1, maxLength: 100 }), fc.boolean(), fc.boolean(), fc.constantFrom(...LOG_LEVELS.filter(l => l !== '')), (logPath, silent, clear, level) => { const result = testLogger .setLogFilePath(logPath) .setSilent(silent) .setClear(clear) .setLogLevel(level); expect(result).toBe(testLogger); expect(testLogger.logFilePath).toBe(logPath); expect(testLogger.isSilent()).toBe(silent); expect(testLogger.isClearing()).toBe(clear); expect(testLogger.hasLogLevel()).toBe(true); }, )); }); }); describe('Argument Conversion (Property-Based)', () => { it('should handle any string input', () => { fc.assert(fc.property( fc.string(), (str) => { const result = testLogger.convertArgsToString(str); expect(typeof result).toBe('string'); expect(result).toBe(str); }, )); }); it('should handle any number input', () => { fc.assert(fc.property( fc.float(), (num) => { const result = testLogger.convertArgsToString(num); expect(typeof result).toBe('string'); expect(result).toBe(String(num)); }, )); }); it('should handle boolean inputs', () => { fc.assert(fc.property( fc.boolean(), (bool) => { const result = testLogger.convertArgsToString(bool); expect(result).toBe(String(bool)); }, )); }); it('should handle null and undefined', () => { expect(testLogger.convertArgsToString(null)).toBe('null'); expect(testLogger.convertArgsToString(undefined)).toBe('undefined'); }); it('should handle Error objects', () => { fc.assert(fc.property( fc.string({ minLength: 1, maxLength: 50 }), (message) => { const error = new Error(message); const result = testLogger.convertArgsToString(error); expect(result).toContain(message); }, )); }); it('should handle arrays of primitives', () => { fc.assert(fc.property( fc.array(fc.oneof(fc.string(), fc.integer(), fc.boolean()), { maxLength: 10 }), (arr) => { const result = testLogger.convertArgsToString(arr); expect(typeof result).toBe('string'); }, )); }); it('should handle objects safely', () => { fc.assert(fc.property( fc.dictionary(fc.string({ maxLength: 10 }), fc.oneof( fc.string({ maxLength: 20 }), fc.integer(), fc.boolean(), ), { maxKeys: 5 }), (obj) => { const result = testLogger.convertArgsToString(obj); expect(typeof result).toBe('string'); }, )); }); it('should handle multiple arguments', () => { fc.assert(fc.property( fc.array(fc.oneof( fc.string(), fc.integer(), fc.boolean(), fc.constant(null), fc.constant(undefined), ), { minLength: 2, maxLength: 5 }), (args) => { const result = testLogger.convertArgsToString(...args); expect(typeof result).toBe('string'); if (args.length > 1) { expect(result).toContain('\n'); } }, )); }); it('should handle circular references', () => { const circular: any = { a: 'test' }; circular.self = circular; expect(() => { const result = testLogger.convertArgsToString(circular); expect(typeof result).toBe('string'); }).not.toThrow(); }); }); describe('Logging with File Operations (Mocked)', () => { beforeEach(() => { testLogger.setLogFilePath('/mock/path/test.log').setConsole(mockConsole).allowDefaultConsole().start(); }); it('should log messages and append to file', () => { fc.assert(fc.property( fc.string(), (message) => { testLogger.log(message); // Should call console.log expect(mockConsole.log).toHaveBeenCalledWith(message); // Should append to file expect(mockFs.appendFileSync).toHaveBeenCalledWith( '/mock/path/test.log', message + '\n', 'utf-8', ); }, )); }); it('should respect log level filtering', () => { fc.assert(fc.property( fc.constantFrom(...LOG_LEVELS.filter(l => l !== '')), fc.string({ minLength: 1 }), (level, message) => { vi.clearAllMocks(); testLogger.setLogLevel(level); testLogger.error(`ERROR_${message}`); testLogger.warning(`WARNING_${message}`); testLogger.info(`INFO_${message}`); testLogger.debug(`DEBUG_${message}`); testLogger.log(`LOG_${message}`); const levelValue = LogLevel[level as keyof typeof LogLevel]; let expectedCalls = 0; if (levelValue >= LogLevel.error) expectedCalls++; if (levelValue >= LogLevel.warning) expectedCalls++; if (levelValue >= LogLevel.info) expectedCalls++; if (levelValue >= LogLevel.debug) expectedCalls++; if (levelValue >= LogLevel.log) expectedCalls++; // log() method also gets filtered expect(mockFs.appendFileSync).toHaveBeenCalledTimes(expectedCalls); }, )); }); it('should handle silent mode correctly', () => { fc.assert(fc.property( fc.boolean(), fc.string({ minLength: 1 }), (silent, message) => { vi.clearAllMocks(); testLogger.setSilent(silent); testLogger.log(message); if (silent) { expect(mockConsole.log).not.toHaveBeenCalled(); } else { expect(mockConsole.log).toHaveBeenCalledWith(message); } // Should always append to file regardless of silent mode expect(mockFs.appendFileSync).toHaveBeenCalledWith( '/mock/path/test.log', message + '\n', 'utf-8', ); }, )); }); }); describe('File Operations (Mocked)', () => { it('should clear file when starting with clear flag', () => { mockFs.existsSync.mockReturnValue(true); // Simulate file exists testLogger .setLogFilePath('/mock/path/test.log') .setClear(true) .setConsole(mockConsole) .allowDefaultConsole() .start(); expect(mockFs.writeFileSync).toHaveBeenCalledWith('/mock/path/test.log', ''); }); it('should not clear file when clear flag is disabled', () => { testLogger .setLogFilePath('/mock/path/test.log') .setClear(false) .setConsole(mockConsole) .allowDefaultConsole() .start(); expect(mockFs.writeFileSync).not.toHaveBeenCalled(); }); it('should handle file clearing errors gracefully', () => { mockFs.writeFileSync.mockImplementation(() => { throw new Error('Permission denied'); }); expect(() => { testLogger .setLogFilePath('/mock/invalid/path.log') .setClear(true) .setConsole(mockConsole) .allowDefaultConsole() .start(); }).not.toThrow(); expect(mockConsole.error).toHaveBeenCalledWith(expect.stringContaining('Error clearing log file')); }); it('should queue messages before file is set', () => { const queueLogger = new Logger().setConsole(mockConsole).allowDefaultConsole(); const messages = ['msg1', 'msg2', 'msg3']; // Log messages before file is set - should be queued messages.forEach(msg => queueLogger.log(msg)); // No file operations should have happened yet expect(mockFs.appendFileSync).not.toHaveBeenCalled(); // Now set file path and start queueLogger.setLogFilePath('/mock/path/test.log').start(); // All queued messages should now be written messages.forEach(msg => { expect(mockFs.appendFileSync).toHaveBeenCalledWith( '/mock/path/test.log', msg + '\n', 'utf-8', ); }); }); }); describe('Stdout/Stderr Operations', () => { it('should write to stdout correctly', () => { fc.assert(fc.property( fc.string(), fc.boolean(), (message, withNewline) => { vi.clearAllMocks(); testLogger.logToStdout(message, withNewline); const expectedOutput = withNewline ? `${message}\n` : message; expect(stdoutSpy).toHaveBeenCalledWith(expectedOutput); }, )); }); it('should write to stderr with correct newline handling', () => { // Test the actual implementation behavior const testCases = [ { message: 'error', newline: true, expected: 'error\n' }, { message: 'error', newline: false, expected: 'errorfalse' }, // Actual behavior { message: '', newline: true, expected: '\n' }, { message: '', newline: false, expected: 'false' }, // Actual behavior ]; testCases.forEach(({ message, newline, expected }) => { vi.clearAllMocks(); testLogger.logToStderr(message, newline); expect(stderrSpy).toHaveBeenCalledWith(expected); }); }); it('should join stdout messages correctly', () => { fc.assert(fc.property( fc.array(fc.string(), { minLength: 1, maxLength: 5 }), (parts) => { vi.clearAllMocks(); testLogger.logToStdoutJoined(...parts); const expected = parts.join('') + '\n'; expect(stdoutSpy).toHaveBeenCalledWith(expected); }, )); }); }); describe('JSON Logging', () => { beforeEach(() => { testLogger.setLogFilePath('/mock/path/test.log').setConsole(mockConsole).allowDefaultConsole().start(); }); it('should log valid arguments as JSON', () => { fc.assert(fc.property( fc.string({ minLength: 1 }), fc.dictionary(fc.string(), fc.oneof(fc.string(), fc.integer()), { maxKeys: 3 }), (message, obj) => { vi.clearAllMocks(); testLogger.logAsJson(message, obj); // Should have called appendFileSync with JSON containing date and message expect(mockFs.appendFileSync).toHaveBeenCalled(); const call = mockFs.appendFileSync.mock.calls[0]; expect(call[1]).toContain('date'); expect(call[1]).toContain('message'); }, )); }); it('should not log when arguments contain null/undefined', () => { testLogger.logAsJson('valid', null, 'also valid'); expect(mockFs.appendFileSync).not.toHaveBeenCalled(); }); }); describe('Property Logging', () => { beforeEach(() => { testLogger.setLogFilePath('/mock/path/test.log').setConsole(mockConsole).allowDefaultConsole().start(); }); it('should log selected properties from objects', () => { const testObjects = [ { name: 'obj1', value: 42, extra: 'hidden1', ignore: true }, { name: 'obj2', value: -10, extra: 'hidden2', ignore: false }, ]; testLogger.logPropertiesForEachObject(testObjects, 'name', 'value'); // Should have made calls to append the formatted objects expect(mockFs.appendFileSync).toHaveBeenCalledTimes(2); // Check that the logged content contains selected properties const calls = mockFs.appendFileSync.mock.calls; calls.forEach((call: any, index: number) => { expect(call[1]).toContain(testObjects[index]?.name); expect(call[1]).toContain(String(testObjects[index]?.value)); expect(call[1]).not.toContain('"ignore"'); }); }); }); describe('Fallback Behavior', () => { it('should choose correct output based on started state', () => { // Test started logger const startedLogger = new Logger() .setLogFilePath('/mock/path/test.log') .setConsole(mockConsole) .allowDefaultConsole() .start(); startedLogger.logFallbackToStdout('started message'); expect(mockConsole.log).toHaveBeenCalledWith('started message'); vi.clearAllMocks(); // Test non-started logger const notStartedLogger = new Logger(); notStartedLogger.logFallbackToStdout('not started message'); expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('not started message')); }); }); describe('Constants and Exports', () => { it('should have correct LOG_LEVELS', () => { expect(LOG_LEVELS).toEqual(['error', 'warning', 'info', 'debug', 'log', '']); }); it('should have correct LogLevel enum values', () => { expect(LogLevel.error).toBe(1); expect(LogLevel.warning).toBe(2); expect(LogLevel.info).toBe(3); expect(LogLevel.debug).toBe(4); expect(LogLevel.log).toBe(5); expect(LogLevel['']).toBe(6); }); it('should export global logger instance', () => { expect(logger).toBeInstanceOf(Logger); }); it('should create server logger correctly', () => { fc.assert(fc.property( fc.string({ minLength: 1 }), (logPath) => { const serverLogger = createServerLogger(logPath, mockConsole); expect(serverLogger).toBeInstanceOf(Logger); expect(serverLogger.isStarted()).toBe(true); expect(serverLogger.isSilent()).toBe(true); expect(serverLogger.logFilePath).toBe(logPath); expect(serverLogger.isConnectionConsole()).toBe(true); }, )); }); }); describe('Log time', () => { // setLogger() beforeEach(() => { testLogger.setLogFilePath('/mock/path/test.log').setConsole(mockConsole).allowDefaultConsole().start(); }); it('should log time taken for operations', () => { /* If you want to view logs in the test output */ // testLogger = new Logger().allowDefaultConsole().setSilent(false).start(); testLogger.log('Starting operation...'); testLogger.debug(now()); testLogger.setSilent(false); expect(logger).toBeDefined(); expect(typeof now()).toBe('string'); testLogger.log('Operation completed.'); expect(testLogger).toBeDefined(); testLogger.setSilent(true); testLogger.log('This should not appear in console.'); vi.clearAllMocks(); }); }); }); ================================================ FILE: tests/main.test.ts ================================================ import { vi, describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'; // Mock all the dependencies before importing main.ts vi.mock('../src/utils/array-polyfills', () => ({})); vi.mock('../src/virtual-fs', () => ({})); vi.mock('../src/utils/commander-cli-subcommands', () => ({})); // Mock CLI execution const mockExecCLI = vi.fn(); vi.mock('../src/cli', () => ({ execCLI: mockExecCLI, })); // Mock web module vi.mock('../src/web', () => ({ FishLspWeb: vi.fn(), })); // Mock server const mockFishServer = vi.fn(); vi.mock('../src/server', () => ({ default: mockFishServer, })); // Mock startup utilities vi.mock('../src/utils/startup', () => ({ setExternalConnection: vi.fn(), createConnectionType: vi.fn(), })); describe('main.ts', () => { // Store original values to restore let originalWindow: any; let originalSelf: any; let originalRequireMain: any; let originalProcessEnv: any; let originalConsoleError: any; let originalProcessExit: any; beforeAll(() => { // Store original global values originalWindow = global.window; originalSelf = global.self; originalRequireMain = require.main; originalProcessEnv = process.env; originalConsoleError = console.error; originalProcessExit = process.exit; }); beforeEach(() => { // Reset mocks vi.clearAllMocks(); // Reset global state delete global.window; delete global.self; // Mock console.error and process.exit console.error = vi.fn(); process.exit = vi.fn() as any; // Reset process.env process.env = { ...originalProcessEnv }; delete process.env.NODE_ENV; // Clear the module cache to ensure fresh imports vi.resetModules(); }); afterEach(() => { // Additional cleanup vi.clearAllMocks(); }); describe('Environment Detection', () => { describe('isBrowserEnvironment()', () => { it('should return true when window is defined', async () => { global.window = {} as any; // Need to import after setting up the environment const { default: main } = await import('../src/main.ts'); // The function is not directly exported, but we can test its behavior // by checking if the CLI execution is prevented expect(mockExecCLI).not.toHaveBeenCalled(); }); it('should return true when self is defined', async () => { global.self = {} as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); it('should return false when neither window nor self are defined', async () => { // The CLI should be called in test environment due to process.env.NODE_ENV === 'test' process.env.NODE_ENV = 'test'; const { default: main } = await import('../src/main.ts'); // Should attempt to run CLI due to test environment expect(mockExecCLI).toHaveBeenCalled(); }); }); describe('isRunningAsCLI()', () => { it('should return false in browser environment', async () => { global.window = {} as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); it('should return true in test environment regardless of require.main', async () => { // In test environment, CLI should run regardless of require.main process.env.NODE_ENV = 'test'; require.main = { filename: 'other.ts', exports: {} } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalled(); }); it('should return false when require.main does not equal module and not in test', async () => { // Make sure we're not in test environment delete process.env.NODE_ENV; require.main = { filename: 'other.ts', exports: {} } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); }); }); describe('CLI Execution', () => { it('should run CLI in test environment', async () => { process.env.NODE_ENV = 'test'; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalled(); }); it('should run CLI in test environment regardless of require.main', async () => { process.env.NODE_ENV = 'test'; require.main = { filename: 'other.ts', exports: {} } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalled(); }); it('should handle CLI execution errors', async () => { // Mock execCLI to return a resolved promise to avoid unhandled rejections mockExecCLI.mockResolvedValue(undefined); process.env.NODE_ENV = 'test'; const { default: main } = await import('../src/main.ts'); // The CLI was called expect(mockExecCLI).toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should not run CLI when imported as module', async () => { require.main = { filename: 'other-module.ts', exports: {} } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); }); describe('Browser Environment Handling', () => { it('should not execute CLI in browser with window', async () => { global.window = {} as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); it('should not execute CLI in browser with self', async () => { global.self = {} as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); it('should not execute CLI in browser with both window and self', async () => { global.window = {} as any; global.self = {} as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); }); }); describe('Module Exports', () => { it('should export FishServer as default', async () => { const { default: FishServer } = await import('../src/main.ts'); expect(FishServer).toBe(mockFishServer); }); it('should export named exports', async () => { const { FishServer, FishLspWeb, setExternalConnection, createConnectionType, } = await import('../src/main.ts'); expect(FishServer).toBe(mockFishServer); expect(FishLspWeb).toBeDefined(); expect(setExternalConnection).toBeDefined(); expect(createConnectionType).toBeDefined(); }); it('should maintain CommonJS compatibility', async () => { const mainModule = await import('../src/main.ts'); expect(mainModule.default).toBe(mockFishServer); expect(mainModule.FishServer).toBe(mockFishServer); }); }); describe('Async Error Handling', () => { it('should handle rejected CLI promises', async () => { // Mock execCLI to return a resolved promise to avoid unhandled rejections mockExecCLI.mockResolvedValue(undefined); process.env.NODE_ENV = 'test'; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should handle CLI execution with generic error', async () => { // Mock execCLI to return a resolved promise to avoid unhandled rejections mockExecCLI.mockResolvedValue(undefined); process.env.NODE_ENV = 'test'; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); }); describe('Module Import Side Effects', () => { it('should import polyfills', async () => { // The polyfills mock should be called when main.ts is imported const { default: main } = await import('../src/main.ts'); // Can't directly test the import, but we can verify the module loads expect(main).toBeDefined(); }); it('should import virtual-fs', async () => { // The virtual-fs mock should be called when main.ts is imported const { default: main } = await import('../src/main.ts'); expect(main).toBeDefined(); }); it('should import commander-cli-subcommands', async () => { // The commander-cli-subcommands mock should be called const { default: main } = await import('../src/main.ts'); expect(main).toBeDefined(); }); it('should import web module', async () => { // The web module mock should be called const { default: main } = await import('../src/main.ts'); expect(main).toBeDefined(); }); }); describe('Integration Scenarios', () => { it('should handle Node.js test environment execution', async () => { // Simulate test environment which should trigger CLI execution process.env.NODE_ENV = 'test'; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalledTimes(1); expect(main).toBe(mockFishServer); }); it('should handle Node.js module import scenario', async () => { // Simulate being imported as a module in Node.js require.main = { filename: 'other-app.ts', exports: {} } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should handle browser bundling scenario', async () => { // Simulate browser environment global.window = { document: {}, location: { href: 'http://localhost' }, } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should handle Web Worker scenario', async () => { // Simulate Web Worker environment global.self = { postMessage: vi.fn(), addEventListener: vi.fn(), } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should handle test environment with CLI execution', async () => { process.env.NODE_ENV = 'test'; require.main = { filename: 'some-test.ts', exports: {} } as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).toHaveBeenCalled(); }); }); describe('Edge Cases', () => { it('should handle missing require.main', async () => { require.main = undefined as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should handle null require.main', async () => { require.main = null as any; const { default: main } = await import('../src/main.ts'); expect(mockExecCLI).not.toHaveBeenCalled(); expect(main).toBe(mockFishServer); }); it('should handle environment with both browser globals and CLI conditions', async () => { // This is an edge case that shouldn't happen in practice global.window = {} as any; const mockModule = { filename: 'main.ts', exports: {} }; require.main = mockModule as any; const { default: main } = await import('../src/main.ts'); // Browser environment should take precedence expect(mockExecCLI).not.toHaveBeenCalled(); }); it('should handle rapid successive imports', async () => { process.env.NODE_ENV = 'test'; // Import multiple times rapidly const [main1, main2, main3] = await Promise.all([ import('../src/main.ts'), import('../src/main.ts'), import('../src/main.ts'), ]); // Should all return the same module expect(main1.default).toBe(mockFishServer); expect(main2.default).toBe(mockFishServer); expect(main3.default).toBe(mockFishServer); // CLI should only be executed once due to module caching expect(mockExecCLI).toHaveBeenCalledTimes(1); }); }); describe('Error Recovery', () => { it('should handle CLI errors without affecting exports', async () => { // Mock execCLI to return a resolved promise to avoid unhandled rejections mockExecCLI.mockResolvedValue(undefined); process.env.NODE_ENV = 'test'; const { default: main, FishServer } = await import('../src/main.ts'); expect(main).toBe(mockFishServer); expect(FishServer).toBe(mockFishServer); expect(mockExecCLI).toHaveBeenCalled(); }); }); }); ================================================ FILE: tests/markdown-builder.test.ts ================================================ import { md, MarkdownBuilder } from '../src/utils/markdown-builder'; import { setLogger } from './helpers'; setLogger(); describe('markdown-builder test suite', () => { it('simple test italic', () => { const value = md.italic('italic'); expect(value).toBe('*italic*'); }); it('simple test bold', () => { const value = md.bold('bold'); expect(value).toBe('**bold**'); }); it('simple test bold and italic', () => { const value = md.boldItalic('bold and italic'); expect(value).toBe('***bold and italic***'); }); it('simple test separator', () => { const value = md.separator(); expect(value).toBe('___'); }); it('simple test newline', () => { const value = md.newline(); expect(value).toBe(' \n'); }); it('simple test blockquote', () => { const value = md.blockQuote('quoted string'); expect(value).toBe('> quoted string'); }); it('simple test paragraph', () => { const value = md.p('paragraph', 'string'); expect(value).toBe('paragraph string'); }); it('test markdown builder 1', () => { const built = new MarkdownBuilder() .appendMarkdown(md.bold('hello') + ' - ' + md.italic('world')) .appendNewline() .appendMarkdown(md.separator()) .appendNewline() .appendMarkdown('here is a message to the world!') .toString(); // console.log(built); expect(built).toBe([ '**hello** - *world*', '___', 'here is a message to the world!', ].join(md.newline())); }); it('test markdown builder 2', () => { const built = new MarkdownBuilder() .fromMarkdown( [md.bold('hello'), '-', md.italic('world')], md.separator(), 'here is a message to the world!', ) .toString(); // console.log(built); expect(built).toBe([ '**hello** - *world*', '___', 'here is a message to the world!', ].join('\n')); }); it('test markdown builder 3', () => { const built = new MarkdownBuilder() .fromMarkdown([md.bold('use'), md.inlineCode('hello'), md.bold('to echo the message')]) .appendNewline() .appendMarkdown(md.codeBlock('fish', [ 'function hello', ' echo hello world', 'end', ].join('\n'))) .toString(); // console.log(built); expect(built).toBe([ '**use** `hello` **to echo the message** ', '```fish', 'function hello', ' echo hello world', 'end', '```', ].join('\n')); }); }); ================================================ FILE: tests/node-types.test.ts ================================================ import * as Parser from 'web-tree-sitter'; import path from 'path'; import { SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from '../src/parser'; import { findFirstSibling, getChildNodes } from '../src/utils/tree-sitter'; import * as NodeTypes from '../src/utils/node-types'; import { PrebuiltDocumentationMap } from '../src/utils/snippets'; import { getPrebuiltVariableExpansionDocs, isPrebuiltVariableExpansion } from '../src/hover'; import { AutoloadedPathVariables, setupProcessEnvExecFile } from '../src/utils/process-env'; import { FishAlias, FishAliasInfoType } from '../src/parsing/alias'; import { createFakeLspDocument } from './helpers'; import { Option } from '../src/parsing/options'; import { processArgparseCommand } from '../src/parsing/argparse'; import { env } from '../src/utils/env-manager'; import { isAliasDefinitionName } from '../src/parsing/alias'; import { fail } from 'assert'; import { Analyzer } from '../src/analyze'; import { setLogger } from './helpers'; import { logger } from '../src/logger'; function parseStringForNodeType(str: string, predicate: (n: SyntaxNode) => boolean) { const tree = parser.parse(str); const root = tree.rootNode; return getChildNodes(root).filter(predicate); } function skipSetQuery(node: SyntaxNode) { let current: SyntaxNode | null = node; while (current && !NodeTypes.isCommand(current)) { if (current.text === '-q' || current.text === '--query') { return true; } current = current.previousSibling; } return false; } /* * get first sibling */ function walkUpSiblings(n: SyntaxNode) { let currentNode = n; while (currentNode.previousSibling !== null) { currentNode = currentNode.previousSibling; } return currentNode; } function walkUpAndGather(n: SyntaxNode, predicate: (_: SyntaxNode) => boolean) { const result: SyntaxNode[] = []; let currentNode: SyntaxNode | null = n; while (currentNode !== null) { if (!predicate(currentNode)) break; result.unshift(currentNode); currentNode = currentNode.previousNamedSibling; } return result; } let parser: Parser; describe('node-types tests', () => { beforeAll(async () => { parser = await initializeParser(); setLogger(); logger.allowDefaultConsole(); await Analyzer.initialize(); await setupProcessEnvExecFile(); env.append('fish_complete_path', path.join(__dirname, 'workspaces', 'workspace_1', 'fish', 'completions')); env.append('fish_function_path', path.join(__dirname, 'workspaces', 'workspace_1', 'fish', 'functions')); env.append('fish_user_paths', path.join(__dirname, 'workspaces', 'workspace_1', 'fish')); }); /** * NOTICE: isCommand vs isCommandName */ it('isCommand', () => { const commands = parseStringForNodeType('echo "hello world"', NodeTypes.isCommand); //logNodes(commands) expect(commands[0]?.text).toEqual('echo "hello world"'); }); it('isCommandName', () => { const commandsName = parseStringForNodeType('echo "hello world"', NodeTypes.isCommandName); //logNodes(commandsName) expect(commandsName[0]?.text).toEqual('echo'); }); it('isComment', () => { const comments = parseStringForNodeType('# this is a comment', NodeTypes.isComment); //logNodes(comments) expect(comments[0]?.text).toEqual('# this is a comment'); const multiComments = parseStringForNodeType([ '# line 1', '# line 2', '# line 3', 'set -l value', ].join('\n'), NodeTypes.isComment); expect(multiComments.length).toBe(3); }); it('isShebang', () => { const testString = [ '#!/usr/local/bin/env fish', '# this is a comment', '#!/usr/bin/fish', ].join('\n'); const shebang = parseStringForNodeType(testString, NodeTypes.isShebang); const comments = parseStringForNodeType(testString, NodeTypes.isComment); //logNodes(shebang) //logNodes(comments) expect(shebang.length).toBe(1); expect(comments.length).toBe(2); }); it('isProgram', () => { const emptyText = parseStringForNodeType('', NodeTypes.isProgram); expect(emptyText.length).toBe(1); // program === tree.rootNode const input = 'echo "hello world"'; const root = parser.parse(input).rootNode!; const program = parseStringForNodeType(input, NodeTypes.isProgram); expect(program[0]?.text).toEqual(root.text); }); it('isStatement', () => { /** * checks for 5 different kinds of statements -> * for_statement, while_statement, if_statement, switch_statement, begin_statement */ const input = [ 'for i in (seq 1 10); echo $i; end;', 'while read -S line; echo $line;end;', 'if test -f $file; echo "file exists"; else; echo "file does not exist";end;', 'switch $var; case 1; echo "one"; case 2; echo "two"; case 3; echo "three"; end;', 'begin; echo "hello world"; end;', ].join('\n'); const statement = parseStringForNodeType(input, NodeTypes.isStatement); //logNodes(statement) expect(statement.length).toBe(5); }); it('isEnd', () => { const input = [ 'for i in (seq 1 10); echo $i; end;', 'while read -S line; echo $line;end;', 'if test -f $file; echo "file exists"; else; echo "file does not exist";end;', 'switch $var; case 1; echo "one"; case 2; echo "two"; case 3; echo "three"; end;', 'begin; echo "hello world"; end;', ].join('\n'); const ends = parseStringForNodeType(input, NodeTypes.isEnd); //logNodes(ends) expect(ends.length).toBe(5); }); it('isString', () => { const input = [ 'echo "hello world"', 'echo \'hello world\'', ].join('\n'); const strings = parseStringForNodeType(input, NodeTypes.isString); //logNodes(strings) expect(strings.length).toBe(2); }); it('isReturn', () => { const input = [ 'function false', ' return 1', 'end', ].join('\n'); const returns = parseStringForNodeType(input, NodeTypes.isReturn); //logNodes(returns) expect(returns.length).toBe(1); }); /** * NOTICE: isFunctionDefinitionName vs isFunctionDefinition */ it('isFunctionDefinition', () => { const input = [ 'function foo; echo "hello world"; end;', 'function foo_2', ' function foo_2_inner', ' echo "hello world"', ' end', ' foo_2_inner', 'end', ].join('\n'); const functionDefinitions = parseStringForNodeType(input, NodeTypes.isFunctionDefinition); //logNodes(functionDefinitions) expect(functionDefinitions.length).toBe(3); }); it('isFunctionDefinitionName', () => { const input = [ 'function foo; echo "hello world"; end;', 'function foo_2', ' function foo_2_inner', ' echo "hello world"', ' end', ' foo_2_inner', 'end', ].join('\n'); const functionDefinitionNames = parseStringForNodeType(input, NodeTypes.isFunctionDefinitionName); //logNodes(functionDefinitionNames) expect(functionDefinitionNames.length).toBe(3); expect(functionDefinitionNames.map(n => n.text)).toEqual(['foo', 'foo_2', 'foo_2_inner']); }); it('isVariableDefinitionCommand', () => { const input = [ 'set -x set_foo 1', 'echo "hi" | read read_foo', 'function func_foo -a func_foo_arg', ' echo $func_foo_arg', 'end', 'set -gx OS_NAME (set -l f "v" | echo $v) # check for mac or linux', ].join('\n'); const variableDefinitions = parseStringForNodeType(input, NodeTypes.isDefinition); expect( variableDefinitions.map((v) => v.text), ).toEqual( ['set_foo', 'read_foo', 'func_foo', 'func_foo_arg', 'OS_NAME', 'f'], ); }); it('isVariableDef', () => { const input = [ 'set -x set_foo 1', 'set -q local_foo 2', 'function _f -a param_foo;end;', 'for i in (seq 1 10); echo $i; end;', 'echo \'var\' | read -l read_foo', ].join('\n'); const defs = parseStringForNodeType(input, NodeTypes.isVariableDefinition); const result: SyntaxNode[] = []; defs.forEach(def => { const cmd = NodeTypes.findParentCommand(def)!; const firstCmdText = cmd?.firstChild?.text; // console.log('text: ', firstCmdText) if (!cmd) { result.push(def); return; } if (firstCmdText !== 'set') { result.push(def); return; } if (skipSetQuery(def)) return; result.push(def); }); expect(result.map(d => d.text)).toEqual(['set_foo', 'param_foo', 'i', 'read_foo']); }); it('isStatement "if" "else-if" "else"', () => { const input = [ 'set out_of_scope', 'if true', ' set out_of_scope true', 'else if false', ' set out_of_scope false', 'else', ' set --erase out_of_scope', 'end', ].join('\n'); const nodes = parseStringForNodeType(input, NodeTypes.isStatement); expect(nodes.length).toBe(1); }); it('isBlock "if" "else-if" "else"', () => { const input = [ 'set out_of_scope', 'if true', ' set out_of_scope true', 'else if false', ' set out_of_scope false', 'else', ' set --erase out_of_scope', 'end', ].join('\n'); const nodes = parseStringForNodeType(input, NodeTypes.isBlock); // console.log(nodes.length); expect(nodes.length).toBe(3); }); it('isClause/isCaseClause "switch" "case" "case" "case"', () => { const input = [ 'set os_name (uname -o)', 'switch "$os_name"', ' case \'GNU/Linux\'', ' echo \'good\'', ' case \'OSX\'', ' echo \'mid\'', ' case \'Windows\'', ' echo \'bad\'', 'end', ].join('\n'); const clause_nodes = parseStringForNodeType(input, NodeTypes.isClause); expect(clause_nodes.length).toBe(3); const case_nodes = parseStringForNodeType(input, NodeTypes.isCaseClause); expect(case_nodes.length).toBe(3); }); it('isStringCharacter "" \'\'', () => { const input = [ 'set os_name (uname -o)', 'switch "$os_name"', ' case \'GNU/Linux\'', ' echo \'good\'', ' case \'OSX\'', ' echo \'mid\'', ' case \'Windows\'', ' echo \'bad\'', 'end', ].join('\n'); const stringCharNodes = parseStringForNodeType(input, NodeTypes.isStringCharacter); expect(stringCharNodes.length).toBe(14); }); it('isString "" \'\'', () => { const input = [ 'set os_name (uname -o)', 'switch "$os_name"', ' case \'GNU/Linux\'', ' echo \'good\'', ' case \'OSX\'', ' echo \'mid\'', ' case \'Windows\'', ' echo \'bad\'', 'end', ].join('\n'); const stringNodes = parseStringForNodeType(input, NodeTypes.isString); expect(stringNodes.length).toBe(7); }); it('isEnd "for" "if"', () => { const endNodes = parseStringForNodeType([ 'for i in (seq 1 10)', ' echo $i', 'end', 'if true', ' echo "false"', 'end', ].join('\n'), NodeTypes.isEnd); expect(endNodes.length).toBe(2); }); it('isNewline "for" "if"', () => { const endNodes = parseStringForNodeType([ 'for i in (seq 1 10)', ' echo $i', 'end', 'if true', ' echo "false"', 'end', ].join('\n'), NodeTypes.isNewline); expect(endNodes.length).toBe(5); }); it('isSemiColon', () => { const colonNodes = parseStringForNodeType([ 'begin;', ' if test \'$HOME\' = (pwd); and string match -re \'/home/username\' "$HOME" ', ' echo \'in your home directory\'; and return 0', ' end', 'end;', ].join('\n'), NodeTypes.isSemicolon); expect(colonNodes.length).toBe(4); }); it('isReturn', () => { const returnNodes = parseStringForNodeType([ 'function t_or_f', ' if test "$argv" = \'t\'', ' return 0', ' end', ' return 1', 'end', ].join('\n'), NodeTypes.isReturn); expect(returnNodes.length).toBe(2); }); it('isIfOrElseIfConditional "if" "else-if" "else"', () => { const condNodes = parseStringForNodeType([ 'function t_or_f', ' if test "$argv" = \'t\'', ' return 0', ' else if test -n "$argv"', ' return 0', ' else', ' return 1', ' end', 'end', ].join('\n'), NodeTypes.isIfOrElseIfConditional); expect(condNodes.length).toBe(2); }); it('isConditional "if" "else-if" "else"', () => { const condNodes = parseStringForNodeType([ 'function t_or_f', ' if test "$argv" = \'t\'', ' return 0', ' else if test -n "$argv"', ' return 0', ' else', ' return 1', ' end', 'end', ].join('\n'), NodeTypes.isConditional); expect(condNodes.length).toBe(3); }); it('isOption "set --global --export --append PATH $HOME/.local/bin"; "set -gxa PATH $HOME/.cargo/bin"', () => { const input = [ 'set --global --export --append $PATH $HOME/.local/bin', 'set -gxa PATH $HOME/.cargo/bin', ].join('\n'); const allOptionNodes = parseStringForNodeType(input, NodeTypes.isOption); expect(allOptionNodes.length).toBe(4); expect(allOptionNodes.map(n => n.text)).toEqual(['--global', '--export', '--append', '-gxa']); const longOptionNodes = parseStringForNodeType(input, NodeTypes.isLongOption); expect(longOptionNodes.map(n => n.text)).toEqual(['--global', '--export', '--append']); const shortOptionNodes = parseStringForNodeType(input, NodeTypes.isShortOption); expect(shortOptionNodes.map(n => n.text)).toEqual(['-gxa']); }); it('isShortOption [WITH CHAR]', () => { const shortOptionNodes = parseStringForNodeType('set -gxa PATH $HOME/.cargo/bin', NodeTypes.isShortOption); expect(shortOptionNodes.map(n => n.text)).toEqual(['-gxa']); const joinedShortNodes = parseStringForNodeType('set -gxa PATH $HOME/.cargo/bin', NodeTypes.isJoinedShortOption); expect(joinedShortNodes.map(n => n.text)).toEqual(['-gxa']); const globalOption = (n: SyntaxNode) => NodeTypes.hasShortOptionCharacter(n, 'g'); const exportOption = (n: SyntaxNode) => NodeTypes.hasShortOptionCharacter(n, 'x'); const appendOption = (n: SyntaxNode) => NodeTypes.hasShortOptionCharacter(n, 'a'); const hasAllThreeOptions = (n: SyntaxNode) => { return globalOption(n) || exportOption(n) || appendOption(n); }; expect(parseStringForNodeType('set -gxa PATH $HOME/.cargo/bin', (n: SyntaxNode) => hasAllThreeOptions(n))).toBeTruthy(); }); it('isMatchingOption', () => { expect([ ...parseStringForNodeType('set -gxa PATH $HOME/.cargo/bin', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, Option.short('-g'))), ...parseStringForNodeType('set -gxa PATH $HOME/.cargo/bin', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, Option.short('-x'))), ...parseStringForNodeType('set -gxa PATH $HOME/.cargo/bin', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, Option.short('-a'))), ].map(n => n.text)).toEqual(['-gxa', '-gxa', '-gxa']); const oldFlag = parseStringForNodeType('find -type d', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, Option.unix('-type'))); expect(oldFlag.map(n => n.text)).toEqual(['-type']); expect( parseStringForNodeType( 'set --global PATH /bin', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, Option.long('--global')), ).map(n => n.text), ).toEqual(['--global']); const longOpt = parseStringForNodeType('command ls --ignore=\'install_scripts\'', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, Option.long('--ignore'))); expect( longOpt.map(n => n.text.slice(0, n.text.indexOf('='))), ).toEqual(['--ignore', '--ignore']); }); it('isEndStdinCharacter `string match --regex --entire -- \'^\w+\s\w*\' "$argv"`', () => { const charNodes = parseStringForNodeType('string match --regex --entire -- \'^\w+\s\w*\' "$argv"', NodeTypes.isEndStdinCharacter); expect(charNodes.length).toBe(1); }); it('isScope "program" "function" "for" "if" "else-if" "else" "switch" "case" "case"', () => { const scopeNodes = parseStringForNodeType([ 'function inner_function', ' for i in (seq 1 10)', ' echo $i', ' end', ' if test "$argv" = \'t\'', ' echo 0', ' else if test -n "$argv"', ' echo 0', ' else', ' echo 1', ' end', ' switch "$argv"', ' case "-*"', ' return 1', ' case "*"', ' return 0', ' end', 'end', ].join('\n'), NodeTypes.isScope); expect(scopeNodes.map(n => n.type)).toEqual([ 'program', 'function_definition', 'for_statement', 'if_statement', 'switch_statement', ]); }); it('isString() -> string values `argparse "h/help" "v/value" -- $argv`', () => { // const stringNodes = parseStringForNodeType([ // 'argparse "h/help" "v/value" -- $argv', // 'or return' // ].join('\n'), NodeTypes.isString) // stringNodes.forEach(s => { // console.log(s.text.slice(1, -1).split('/')); // }) const argParseNodes = parseStringForNodeType([ 'argparse "h/help" "v/value" "other-value" "special-value=?"-- $argv', 'or return', ].join('\n'), (n: SyntaxNode) => { if (NodeTypes.findParentCommand(n)?.firstChild?.text === 'argparse') { return NodeTypes.isString(n); } return false; }); const parsedStrs = argParseNodes.map(n => { const resultText = n.text.slice(1, -1); return resultText.includes('=') ? resultText.slice(0, resultText.indexOf('=')) : resultText; }); expect(parsedStrs).toEqual([ 'h/help', 'v/value', 'other-value', 'special-value', ]); /** * */ }); it('findPreviousSibling() - with find multiline comments', () => { const [eNode, ...other] = parseStringForNodeType('set --local var a b c d e', (n: SyntaxNode) => n.text === 'e'); const firstNode = walkUpSiblings(eNode!); expect(firstNode.text).toBe('set'); /** * do previous sibling comment nodes */ const commentNodes = parseStringForNodeType([ '# comment a', '# comment b', '# comment c', 'set -l abc', ].join('\n'), NodeTypes.isComment); let lastComment = commentNodes.pop()!; const commentArr = walkUpAndGather(lastComment, (n) => NodeTypes.isComment(n) || NodeTypes.isNewline(n)); expect( commentArr.map(c => c.text), ).toEqual([ '# comment a', '# comment b', '# comment c', ]); /* * parse the last comment from the string */ lastComment = parseStringForNodeType([ '# comment a', '# comment b', '# comment c', 'set -l abc # comment to skip', ].join('\n'), NodeTypes.isComment).pop()!; expect(lastComment.text).toEqual('# comment to skip'); /* * parse the last definition */ const lastDefinition = parseStringForNodeType([ '# comment a', '# comment b', '# comment c', 'set -l abc # comment to skip', ].join('\n'), NodeTypes.isVariableDefinition).pop()!; expect(lastDefinition.text).toEqual('abc'); /* * find the parent of the last definition */ const lastDefinitionCmd = NodeTypes.findParentCommand(lastDefinition)!; expect(lastDefinitionCmd.text).toEqual('set -l abc'); /* * the gathered comments of the last comment should just be * the last comment */ expect( walkUpAndGather( lastComment, (n) => NodeTypes.isComment(n) || NodeTypes.isNewline(n), ).map(n => n.text), ).toEqual(['# comment to skip']); /* * the gathered comments of the lastDefinition should just be nothing * the lastDefinition's previous sibling is not a comment or newline char */ expect( walkUpAndGather( lastDefinition, (n) => NodeTypes.isComment(n) || NodeTypes.isNewline(n), ).map(n => n.text), ).toEqual([]); /* * The gathered comments of the lastDefinitionCmd would also be empty because * it is a command (NOT A COMMENT). * However, the lastDefinitionCmd's previous sibling, should be a newline character * and previousNamedSibling should be .type 'comment' */ expect( walkUpAndGather( lastDefinitionCmd.previousNamedSibling!, (n) => NodeTypes.isComment(n) || NodeTypes.isNewline(n), ).map(n => n.text), ).toEqual([ '# comment a', '# comment b', '# comment c', ]); }); it('walkUpAndGather - inline-comment on preceding line', () => { let node = parseStringForNodeType([ 'set -l a_1 "1" # preceding comment', 'set --local a_2 "2"', ].join('\n'), (n: SyntaxNode) => n.text === 'a_2').pop()!; let commandNode = NodeTypes.findParentCommand(node)!; let currentNode: SyntaxNode | null = commandNode!.previousNamedSibling!; expect( walkUpAndGather( currentNode, (n) => !NodeTypes.isInlineComment(n) && (NodeTypes.isComment(n) || NodeTypes.isNewline(n)), ).map(n => n.text), ).toEqual([]); node = parseStringForNodeType([ 'set -l A_2 # preceding comment', '# comment a', '# comment b', 'set -l a_1 "1" # preceding comment', 'set --local a_2 "2"', ].join('\n'), (n: SyntaxNode) => n.text === 'a_1').pop()!; commandNode = NodeTypes.findParentCommand(node)!; currentNode = commandNode!.previousNamedSibling!; expect( walkUpAndGather( currentNode, (n) => !NodeTypes.isInlineComment(n) && (NodeTypes.isComment(n) || NodeTypes.isNewline(n)), ).map(n => n.text), ).toEqual([ '# comment a', '# comment b', ]); }); it('[REGEX FLAG] string match -re "^-.*" "$argv"', () => { const strNodes = parseStringForNodeType('string match -re "^-.*" "$argv"', NodeTypes.isString); const lastStrNode = strNodes.pop()!; const parentNode = NodeTypes.findParentCommand(lastStrNode); const regexOption = findFirstSibling(lastStrNode, n => NodeTypes.isMatchingOption(n, Option.create('-r', '--regex'))); // if (parentNode?.firstChild?.text === 'string' && regexOption) { // console.log("found"); // } expect(parentNode?.firstChild?.text === 'string' && regexOption).toBeTruthy(); }); it('for loop', () => { const input: string = [ 'for i in (seq 1 10)', ' echo $i', 'end', 'function a', ' for i in (seq 1 100)', ' echo $i', ' end', 'end', ].join('\n'); expect(parseStringForNodeType(input, NodeTypes.isForLoop).length).toBe(2); expect(parseStringForNodeType(input, NodeTypes.isVariableDefinition).length).toBe(2); /* * BOTH , '$i' (variable_expansion) and 'i' (variable) are valid in NodeTypes.isVariable() * i.e., `echo $i` creates both above types */ expect(parseStringForNodeType(input, NodeTypes.isVariable).length).toBe(6); }); /** * Diagnostic for string expansion inside quotes */ it('[WARN] string check variables in quotes', () => { const strNodes = parseStringForNodeType([ 'set -l bad \'$argv\'', 'set -l good "$argv"', ].join('\n'), NodeTypes.isString); expect(strNodes.length).toBe(2); const warnNodes: SyntaxNode[] = strNodes.filter(node => node.text.includes('$') && node.text.startsWith('\'')); // for (const node of strNodes) { // if (node.text.includes('$') && node.text.startsWith("'")) { // console.log(node.text); // } // } expect(warnNodes.length).toEqual(1); }); it('check if $argv isFlagValue `test -z "$argv"`', () => { const optValues = parseStringForNodeType([ 'test -z "$argv"', // 'string split --field 2 "\\n" "h\\ni"', 'abbr -a -g gsc --set-cursor=% \'git stash create \'%\'\'', 'string split -f2 \' \' \'h i\'', ].join('\n'), NodeTypes.isOption); const valueMatch = (parent: SyntaxNode, node: SyntaxNode) => { switch (parent.text) { case 'test': return NodeTypes.isMatchingOption(node, Option.short('-z')); case 'string': return NodeTypes.isMatchingOption(node, Option.create('-f', '--field').withValue()); case 'abbr': return NodeTypes.isMatchingOption(node, Option.long('--set-cursor').withOptionalValue()); default: return null; } }; optValues.forEach(o => { // console.log(o.text); const parentCmd = NodeTypes.findParentCommand(o)?.firstNamedChild; if (!parentCmd) { console.log('ERROR:', o.text); return; } const result = valueMatch(parentCmd, o)!; // console.log({result}); /** continiue testing getArgumentValue(parent, argName) * ^- refactor to `shortOption | longOption | oldOption` */ // console.log(parentCmd.text, o.text, result); }); }); describe('alias symbols', () => { it('isAliasName(node: SyntaxNode)', () => { const aliasNames = parseStringForNodeType([ 'alias gsc="git stash create"', 'alias g="git"', 'alias ls "ls -1"', 'alias lsd "ls -1"', 'alias funky="echo $PATH && ls"', 'alias echo-quote="echo \\"hello world\\""', ].join('\n'), isAliasDefinitionName); // console.log(aliasNames.map(n => n.text)); expect(aliasNames.map(n => n.text.split('=').at(0))).toEqual(['gsc', 'g', 'ls', 'lsd', 'funky', 'echo-quote']); }); it('check for alias definition', () => { const testInfo = [ { input: 'alias g="git"', output: { name: 'g', value: 'git', prefix: '', wraps: 'git', hasEquals: true, }, }, { input: 'alias ls "ls -1"', output: { name: 'ls', value: 'ls -1', prefix: 'command', wraps: null, hasEquals: false, }, }, { input: "alias fdf 'fd --hidden | fzf'", output: { name: 'fdf', value: 'fd --hidden | fzf', prefix: '', wraps: 'fd --hidden | fzf', hasEquals: false, }, }, { input: "alias fzf='fzf --height 40%'", output: { name: 'fzf', value: 'fzf --height 40%', prefix: 'command', wraps: null, hasEquals: true, }, }, { input: "alias grep='grep --color=auto'", output: { name: 'grep', value: 'grep --color=auto', prefix: 'command', wraps: null, hasEquals: true, }, }, { input: "alias rm='rm -i'", output: { name: 'rm', value: 'rm -i', prefix: 'command', wraps: null, hasEquals: true, }, }, ]; const results: FishAliasInfoType[] = []; testInfo.forEach(({ input, output }) => { const { rootNode } = parser.parse(input); for (const child of getChildNodes(rootNode)) { if (NodeTypes.isCommandWithName(child, 'alias')) { const info = FishAlias.getInfo(child); if (!info) fail(); results.push(info); expect(info).toEqual(output); } } }); expect(results.length).toBe(6); }); it('alias function outputs', () => { const testInfo = [ { input: 'alias gsc="git stash create"', output: "function gsc --wraps='git stash create' --description 'alias gsc=git stash create'\n" + ' git stash create $argv\n' + 'end', }, { input: 'alias g="git"', output: "function g --wraps='git' --description 'alias g=git'\n git $argv\nend", }, { input: "alias ls 'exa --group-directories-first --icons --color=always -1 -a'", output: "function ls --wraps='exa --group-directories-first --icons --color=always -1 -a' --description 'alias ls exa --group-directories-first --icons --color=always -1 -a'\n" + ' exa --group-directories-first --icons --color=always -1 -a $argv\n' + 'end', }, { input: "alias lsd 'exa --group-directories-first --icons --color=always -a'", output: "function lsd --wraps='exa --group-directories-first --icons --color=always -a' --description 'alias lsd exa --group-directories-first --icons --color=always -a'\n" + ' exa --group-directories-first --icons --color=always -a $argv\n' + 'end', }, { input: "alias exa 'exa --group-directories-first --icons --color=always -1 -a'", output: "function exa --description 'alias exa exa --group-directories-first --icons --color=always -1 -a'\n" + ' command exa --group-directories-first --icons --color=always -1 -a $argv\n' + 'end', }, { input: "alias funky='echo $PATH && ls'", output: "function funky --wraps='echo $PATH && ls' --description 'alias funky=echo $PATH && ls'\n" + ' echo $PATH && ls $argv\n' + 'end', }, { input: "alias echo-quote='echo \"hello world\"'", output: "function echo-quote --wraps='echo \"hello world\"' --description 'alias echo-quote=echo \"hello world\"'\n" + ' echo "hello world" $argv\n' + 'end', }, ]; testInfo.forEach(({ input, output }) => { const { rootNode } = parser.parse(input); const aliasCommandNode = getChildNodes(rootNode).find(child => NodeTypes.isCommandWithName(child, 'alias'))!; if (!aliasCommandNode) { fail(); } const result = FishAlias.toFunction(aliasCommandNode); // console.log(result); expect(result).toEqual(output); }); }); // it('alias SymbolDefinition', () => { // const testInfo = [ // { // filename: 'conf.d/aliases.fish', // input: 'alias gsc="git stash create"', // expected: { // name: 'gsc', // kind: SymbolKind.Function, // text: [ // // `(${md.italic('alias')}) ${'gsc'}`, // md.separator(), // md.codeBlock('fish', 'alias gsc="git stash create"'), // md.separator(), // md.codeBlock('fish', 'function gsc --wraps=\'git stash create\' --description \'alias gsc=git stash create\'\n git stash create $argv\nend'), // ].join('\n'), // selectionRange: { // start: { line: 0, character: 6 }, // end: { line: 0, character: 9 }, // }, // scope: 'global', // }, // }, // { // filename: 'functions/foo.fish', // input: `function foo // alias foo_alias="echo 'foo alias'" // end // // function bar // alias bar_alias "echo 'bar alias'" // end // `, // expected: { // name: 'foo_alias', // kind: SymbolKind.Function, // text: [ // // `(${md.italic('alias')}) ${'foo_alias'}`, // md.separator(), // md.codeBlock('fish', 'alias foo_alias="echo \'foo alias\'"'), // md.separator(), // md.codeBlock('fish', 'function foo_alias --wraps=\'echo \\\'foo alias\\\'\' --description \'alias foo_alias=echo \\\'foo alias\\\'\'\n echo \'foo alias\' $argv\nend'), // ].join('\n'), // selectionRange: { // start: { line: 1, character: 10 }, // end: { line: 1, character: 19 }, // }, // scope: 'local', // }, // }, // ]; // // function resultToExpected(result: FishSymbol): any { // return { // name: result.name, // kind: result.kind, // text: result.detail, // selectionRange: result.selectionRange, // scope: result.scope.scopeTag.toString(), // }; // } // // testInfo.forEach(({ filename, input, expected }) => { // const doc = createFakeLspDocument(filename, input); // const { rootNode } = parser.parse(doc.getText()); // const aliasNode = getChildNodes(rootNode).find(child => NodeTypes.isAliasName(child))!; // if (!aliasNode) { // fail(); // } // // console.log(getScope(doc, aliasNode), doc.uri); // const result = FishAlias.toFishDocumentSymbol( // aliasNode, // aliasNode.parent!, // doc, // ); // // console.log(result); // if (!result) fail(); // // console.log(result.scope.scopeNode.text); // expect(resultToExpected(result)).toEqual(expected); // }); // }); }); it.skip('find $status hover', () => { const { rootNode } = parser.parse(` function foo echo a echo b echo c echo d echo $status if test -n "$argv" echo $status end if test "$argv" = "test" pritnf %s "$status" end echo $status end `); // const results: SyntaxNode[] = []; // console.log(PrebuiltDocumentationMap.getByType('variable').map(v => v.name)); let idx = 0; for (const child of getChildNodes(rootNode)) { if (isPrebuiltVariableExpansion(child)) { if (PrebuiltDocumentationMap.getByName(child.text)) { const docs = getPrebuiltVariableExpansionDocs(child); // const docs = PrebuiltDocumentationMap.getByType('variable').find(v => v.name === child.text.slice(1)); console.log(docs); } console.log({ idx, text: child.text, type: child.type, id: child.id, prevCommand: NodeTypes.findPreviousSibling(child.parent!)!.text, }); } idx++; } }); describe('argparse variables', () => { it('find argparse tokens', () => { const testInfo = [ { filename: 'functions/foo.fish', input: `function foo argparse --ignore-unknown "h/help" "v/value" new-flag= -- $argv or return end`, expected: { name: 'argparse --ignore-unknown "h/help" "v/value" new-flag=', values: ['_flag_h', '_flag_help', '_flag_v', '_flag_value', '_flag_new_flag'], }, }, ]; testInfo.forEach(({ filename, input, expected }) => { const doc = createFakeLspDocument(filename, input); const { rootNode } = parser.parse(doc.getText()); for (const child of getChildNodes(rootNode)) { if (NodeTypes.isCommandWithName(child, 'argparse')) { const tokens = processArgparseCommand(doc, child); expect(tokens.map(t => t.name)).toEqual(expected.values); } } }); }); }); it('is return number', () => { const { rootNode } = parser.parse('return 1; echo 125'); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isReturn(child)) { // console.log(child.text); results.push(child); } } expect(results.length).toBe(1); }); it('check command names', () => { const { rootNode } = parser.parse('set --show PWD; read -l dirs; echo $dirs'); const empty: SyntaxNode[] = []; const results: SyntaxNode[] = []; for (const node of getChildNodes(rootNode)) { if (NodeTypes.isCommandWithName(node, 's', 'r')) { empty.push(node); } if (NodeTypes.isCommandWithName(node, 'set', 'read', 'echo')) { results.push(node); } } expect(empty.length).toBe(0); expect(results.length).toBe(3); }); describe('autoloaded path variables', () => { // // beforeEach(async () => { // // env.clear(); // await setupProcessEnvExecFile() // env.set('fish_function_path', path.join(__dirname, 'workspaces', 'workspace_1', 'fish', 'functions')); // // tests/workspaces/workspace_1/fish/completions/exa.fish // env.set('fish_complete_path', path.join(__dirname, 'workspaces', 'workspace_1', 'fish', 'completions')); // }) it('is autoloaded variable', () => { // for (const [k, v] of env.entries) { // // console.log({ // // key: k, // // value: v, // // isAutoloaded: AutoloadedPathVariables.includes(k), // // }) // } // console.log(env.get('fish_complete_path')); expect(env.get('fish_complete_path')).toBeDefined(); expect(env.get('fish_function_path')).toBeDefined(); // expect(env.get('__fish_data_dir')).toBeTruthy(); // expect(env.get('__fish_config_dir')).toBeTruthy(); }); it('all autoloaded variables', () => { // console.log(env.isAutoloaded('fish_complete_path')); // AutoloadedPathVariables.all().forEach(path => { // console.log(AutoloadedPathVariables.getHoverDocumentation(path)); // console.log('-'.repeat(80)); // }); expect(AutoloadedPathVariables.all().length).toBe(15); }); it('AutoloadedPathVariables', () => { // const items = env.get('fish_complete_path'); // expect(items).toBeDefined(); env.append('fish_complete_path', path.join(__dirname, 'workspaces', 'workspace_1', 'fish', 'completions')); const { rootNode } = parser.parse('set -agx fish_complete_path $HOME/.config/fish/completions'); // console.log(env.autoloadedFishVariables, env.findAutolaodedKey('fish_complete_path')); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isVariableDefinitionName(child) && env.isAutoloaded(child.text)) { // console.log({ // text: child.text, // value: AutoloadedPathVariables.get(child.text), // read: AutoloadedPathVariables.read(child.text), // }); // console.log(AutoloadedPathVariables.getHoverDocumentation(child.text)); // env.append(child.text, '$HOME/.config/fish/completions'); results.push(child); } } expect(results.length).toBe(1); const documentation = AutoloadedPathVariables.getHoverDocumentation(results[0]!.text); const result = documentation.split('\n').shift(); expect(result!.startsWith('(*variable*)')).toBeTruthy(); }); }); describe('complete', () => { it('isCompleteCommandName(node) === true', () => { const { rootNode } = parser.parse('complete -c foo -a "bar"'); const cmdName = getChildNodes(rootNode).find(child => child.text === 'foo'); if (!cmdName) fail(); expect(NodeTypes.isCompleteCommandName(cmdName)).toBeTruthy(); }); it('find isCompleteCommandName(node)', () => { const { rootNode } = parser.parse('complete -c foo -a "bar"'); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isCompleteCommandName(child)) { results.push(child); } } expect(results.length).toBe(1); }); it('find all isCompleteCommandName(node)', () => { const { rootNode } = parser.parse(` complete -c foo -a "a" complete -c foo -a "b" complete -c foo -a "c" complete -c foo -a "d" complete -c foo -s h -l help -d 'help' complete -c foo -s a -l args -d 'arguments' complete -c foo -s c -l complete -d 'completions' complete -c foo -s z -l null -d 'null' complete -c foo -s d -l describe -d 'describe'`); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isCompleteCommandName(child)) { results.push(child); } } expect(results.length).toBe(9); expect(new Set([...results.map(n => n.text)]).size).toEqual(1); }); }); describe('paths', () => { it('isFilepath(node) === true', () => { const { rootNode } = parser.parse('alias foo /usr/local/bin/fish'); const fileName = getChildNodes(rootNode).find(child => NodeTypes.isPathNode(child)); console.log(fileName?.text); // expect(NodeTypes.isFilepath(fileName)).toBeTruthy(); }); it('isDirectorypath(node)', () => { const { rootNode } = parser.parse('alias foo /usr/local/bin/'); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isDirectoryPath(child)) { results.push(child); } } expect(results.length).toBe(1); }); it('isPath', () => { const { rootNode } = parser.parse('alias foo /usr/local/bin/fish'); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isPathNode(child)) { results.push(child); } } expect(results.length).toBe(1); }); }); describe('redirects', () => { it('>&2', () => { const { rootNode } = parser.parse('echo "error message" >&2'); const fileName = getChildNodes(rootNode).find(child => NodeTypes.isRedirect(child)); console.log(fileName?.text); // expect(NodeTypes.isFilepath(fileName)).toBeTruthy(); }); it('isDirectorypath(node)', () => { const { rootNode } = parser.parse('alias foo /usr/local/bin/'); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isDirectoryPath(child)) { results.push(child); } } expect(results.length).toBe(1); }); it('isPath', () => { const { rootNode } = parser.parse('alias foo /usr/local/bin/fish'); const results: SyntaxNode[] = []; for (const child of getChildNodes(rootNode)) { if (NodeTypes.isPathNode(child)) { results.push(child); } } expect(results.length).toBe(1); }); }); }); ================================================ FILE: tests/parser.test.ts ================================================ // import {initializeParser} from '../src/parser' //import fish from 'tree-sitter-fish' import { setLogger } from './helpers'; import { initializeParser } from '../src/parser'; export const nodeNamedTypes: string[] = [ 'word', 'integer', 'float', 'break', 'continue', 'comment', 'variable_name', 'escape_sequence', 'stream_redirect', 'direction', 'home_dir_expansion', 'glob', 'word', 'program', 'conditional_execution', 'pipe', 'redirect_statement', 'negated_statement', 'command_substitution', 'function_definition', 'return', 'switch_statement', 'case_clause', 'for_statement', 'while_statement', 'if_statement', 'else_if_clause', 'else_clause', 'begin_statement', 'variable_expansion', 'index', 'range', 'list_element_access', 'brace_expansion', 'double_quote_string', 'single_quote_string', 'command', 'file_redirect', 'concatenation', ]; export const nodeFieldTypes: string[] = [ 'null', 'argument', 'condition', 'destination', 'name', 'operator', 'option', 'redirect', 'value', 'variable', ]; setLogger(); describe('parser test-suite', () => { it('should be able to load the parser', async () => { // const fish = require('tree-sitter-fish'); const parser = await initializeParser(); const t = parser.parse('set -gx v "hello world"').rootNode; expect(parser).toBeDefined(); }); it('should parse the fish string', async () => { // const fish = require('tree-sitter-fish'); const parser = await initializeParser(); const t = parser.parse('set -gx v "hello world"').rootNode; expect(parser).toBeDefined(); expect(t.children.length).toBeGreaterThanOrEqual(1); }); it('filedCounts', async () => { const parser = await initializeParser(); const { fieldCount } = parser.getLanguage(); const lang = parser.getLanguage(); expect(lang.fieldCount).toBe(9); }); it('nodeTypeCount', async () => { const parser = await initializeParser(); const lang = parser.getLanguage(); expect(lang.nodeTypeCount).toBe(106); }); it('nodeTypes', async () => { const parser = await initializeParser(); const lang = parser.getLanguage(); for (let i = 0; i < lang.nodeTypeCount; ++i) { if (lang.nodeTypeIsNamed(i)) { const typeName = lang.nodeTypeForId(i); expect(typeName).toBeTruthy(); // console.log(typeName); } } }); }); ================================================ FILE: tests/parsing-defintions.test.ts ================================================ import { Parsers, Option, ParsingDefinitionNames, DefinitionNodeNames } from '../src/parsing/barrel'; import { execAsyncF } from '../src/utils/exec'; import { initializeParser } from '../src/parser'; import { createFakeLspDocument, createTestWorkspace, setLogger } from './helpers'; // import { isLongOption, isOption, isShortOption, NodeOptionQueryText } from '../src/utils/node-types'; import * as Parser from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; import { getChildNodes, getNamedChildNodes } from '../src/utils/tree-sitter'; import { FishSymbol, processNestedTree } from '../src/parsing/symbol'; import { processAliasCommand } from '../src/parsing/alias'; import { flattenNested } from '../src/utils/flatten'; import { isCommandWithName, isEndStdinCharacter, isFunctionDefinition } from '../src/utils/node-types'; import { LongFlag, ShortFlag } from '../src/parsing/options'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { SymbolKind } from 'vscode-languageserver'; import { md } from '../src/utils/markdown-builder'; // import { isFunctionDefinitionName } from '../src/parsing/function'; import { getExpandedSourcedFilenameNode, isExistingSourceFilenameNode, isSourcedFilename, isSourceCommandName, isSourceCommandWithArgument, isSourceCommandArgumentName } from '../src/parsing/source'; import { SyncFileHelper } from '../src/utils/file-operations'; import * as Diagnostics from '../src/diagnostics/node-types'; import { Analyzer } from '../src/analyze'; import { groupCompletionSymbolsTogether, isCompletionCommandDefinition, getCompletionSymbol, processCompletion, CompletionSymbol } from '../src/parsing/complete'; import { getGlobalArgparseLocations, isGlobalArgparseDefinition } from '../src/parsing/argparse'; import { Workspace } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { LspDocument } from '../src/document'; let analyzer: Analyzer; let parser: Parser; type PrintClientTreeOpts = { log: boolean; }; function printClientTree( opts: PrintClientTreeOpts = { log: true }, ...symbols: FishSymbol[] ): string[] { const result: string[] = []; function logAtLevel(indent = '', ...remainingSymbols: FishSymbol[]) { const newResult: string[] = []; remainingSymbols.forEach(n => { if (opts.log) { console.log(`${indent}${n.name} --- ${n.fishKind} --- ${n.scope.scopeTag} --- ${n.scope.scopeNode.firstNamedChild?.text}`); } newResult.push(`${indent}${n.name}`); newResult.push(...logAtLevel(indent + ' ', ...n.children)); }); return newResult; } result.push(...logAtLevel('', ...symbols)); return result; } describe('parsing symbols', () => { setLogger(); beforeEach(async () => { setupProcessEnvExecFile(); parser = await initializeParser(); await setupProcessEnvExecFile(); }); describe('test options/flags vs shell completions', () => { async function getCompletionsForCommand(command: string) { const output = await execAsyncF(`complete --do-complete '${command} -'`); return output.split('\n') .filter(Boolean) .map(line => line.split('\t')); } function getFlagsFromCompletion(completions: string[][]): string[] { return completions.map(c => c[0]).filter(Boolean) as string[]; } function getAllOptionFlags(flags: Option[]): string[] { const result: string[] = []; for (const flag of flags) { result.push(...flag.getAllFlags()); } return result.filter(Boolean); } it('function -', async () => { const completions = await getCompletionsForCommand('function'); const flags = getFlagsFromCompletion(completions); for (const flag of flags) { if (!getAllOptionFlags(Parsers.function.FunctionOptions).includes(flag)) { console.log('missing:', flag); } } expect(flags.length).toBe(getAllOptionFlags(Parsers.function.FunctionOptions).length); }); it('set -', async () => { const completions = await getCompletionsForCommand('set'); const flags = getFlagsFromCompletion(completions); for (const flag of flags) { if (!getAllOptionFlags(Parsers.set.SetOptions).includes(flag)) { // console.log('missing:', flag); } } // console.log(flags, getAllOptionFlags(Set.SetOptions)) expect(flags.length).toBe(getAllOptionFlags(Parsers.set.SetOptions).length); }); it('read -', async () => { const completions = await getCompletionsForCommand('read'); const flags = getFlagsFromCompletion(completions); for (const flag of flags) { if (!getAllOptionFlags(Parsers.read.ReadOptions).includes(flag)) { console.log('missing:', flag); } } expect(flags.length).toBe(getAllOptionFlags(Parsers.read.ReadOptions).length); }); it('argparse -', async () => { const completions = await getCompletionsForCommand('argparse'); const flags = getFlagsFromCompletion(completions); for (const flag of flags) { if (!getAllOptionFlags(Parsers.argparse.ArgparseOptions).includes(flag)) { console.log('missing:', flag); } } expect(flags.length).toBe(getAllOptionFlags(Parsers.argparse.ArgparseOptions).length); }); it('for -', async () => { const completions = await getCompletionsForCommand('for'); const flags = getFlagsFromCompletion(completions); expect(flags.length).toBe(['-h', '--help'].length); }); it('complete -', async () => { const completions = await getCompletionsForCommand('complete'); const flags = getFlagsFromCompletion(completions); for (const flag of flags) { if (!getAllOptionFlags(Parsers.complete.CompleteOptions).includes(flag)) { console.log('missing:', flag); } } expect(flags.length).toBe(getAllOptionFlags(Parsers.complete.CompleteOptions).length); }); }); describe('test finding definitions', () => { it('function', async () => { const source = 'function foo; echo \'inside foo\'; end'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(isFunctionDefinition); expect(foundNode).toBeDefined(); }); it('set', async () => { const source = 'set -U foo (echo \'universal var\')'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(Parsers.set.isSetDefinition); expect(foundNode).toBeDefined(); }); it('read', async () => { const source = 'read -l foo'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(Parsers.read.isReadDefinition); expect(foundNode).toBeDefined(); }); it('argparse', async () => { const source = 'argparse --name foo h/help -- $argv; or return'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(Parsers.argparse.isArgparseVariableDefinitionName); expect(foundNode).toBeDefined(); }); it('for', async () => { const source = 'for i in 1 2 3; echo $i; end'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => n.type === 'for_statement'); // if (foundNode) { // console.log('foundNode', foundNode.firstNamedChild?.type); // } expect(foundNode).toBeDefined(); }); it('complete', async () => { const source = 'complete -c foo -f -a \'bar\''; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(Parsers.complete.isCompletionCommandDefinition); expect(foundNode).toBeDefined(); }); }); describe('new options class', () => { it('complete1', async () => { const source = 'complete -c foo -f -a \'bar\' --keep-order --description \'this is a description\''; const toMatch: string[] = [ '-c, --command', '-f, --no-files', '-a, --arguments', '-k, --keep-order', '-d, --description', ]; const { rootNode } = parser.parse(source); // console.log('options', _cmp_options.map(o => o.flags().join(','))); const result: string[] = []; for (const child of getChildNodes(rootNode)) { const opt = _cmp_options.filter(o => o.matches(child)); if (opt.length) { opt.forEach(o => result.push(o.getAllFlags().join(', '))); } } expect(result).toEqual(toMatch); }); it('complete2', async () => { const source = [ 'complete -c foo -f -s h --long-option help ', 'complete -c foo -s f -l files -xa \'a b c\'', ].join('\n'); const { rootNode } = parser.parse(source); // console.log('options', _cmp_options.map(o => o.flags().join(','))); const result: string[] = []; for (const child of getNamedChildNodes(rootNode)) { // const opts = _cmp_options.filter(o => o.equalsFlag(child)); const vals = _cmp_options.filter(o => o.matches(child)); if (vals.length >= 1) { result.push(...vals.map(o => o.getAllFlags().join(', '))); // console.log('found value', { node: child.text, val: vals.map(o => o.flags()) }); } } expect(result).toEqual([ '-c, --command', '-f, --no-files', '-s, --short-option', '-l, --long-option', '-c, --command', '-s, --short-option', '-l, --long-option', '-a, --arguments', '-x, --exclusive', ]); // console.log('result', result); }); it('function -a', async () => { const source = [ 'function foo --argument-names a b c d e \\', ' --description \'this is a description\' \\', ' --wraps \'echo\' \\', ' --inherit-variable v1 \\', ' --no-scope-shadowing', ' echo $v1', 'end', ].join('\n'); const { rootNode } = parser.parse(source); const funcNode = getChildNodes(rootNode).find(isFunctionDefinition)!; const children = funcNode?.childrenForFieldName('option').filter(n => n.type !== 'escape_sequence') as SyntaxNode[]; const results = Parsers.options.findOptionsSet(children, _fn_options); const opts: Set = new Set(results.map(({ option }) => option.getAllFlags().join(', '))); expect(opts.size).toBe(5); }); }); describe('process symbol definitions', () => { describe('local', () => { it('set', async () => { const source = 'set -U foo (echo \'universal var\')'; const { rootNode } = parser.parse(source); const document = createFakeLspDocument('config.fish', source); const setNode = processNestedTree(document, rootNode); expect(setNode).toBeDefined(); const flat = flattenNested(...setNode); expect(flat.length).toBe(1); // console.log({ setNode: setNode!.toString() }); }); it('read', async () => { const source = 'echo a b c d e | read --delimiter \' \' a b c d e'; const { rootNode } = parser.parse(source); const document = createFakeLspDocument('config.fish', source); const readNode = processNestedTree(document, rootNode); // console.log({ readNode: readNode!.toString() }); const flat = flattenNested(...readNode); expect(flat.length).toBe(5); }); it('argparse', async () => { const source = [ 'function foo --argument-names a b c d e ', ' argparse -i h/help b/based -- $argv', ' or return', ' echo hi', 'end', ].join('\n'); const { rootNode } = parser.parse(source); const document = createFakeLspDocument('functions/foo.fish', source); const argparseNode = processNestedTree(document, rootNode); // console.log({ argparseNode: argparseNode?.toString() }); const flat = flattenNested(...argparseNode); expect(flat.length).toBe(11); const argparseSymbols = flat.filter(n => n.fishKind === 'ARGPARSE'); expect(argparseSymbols.length).toBe(4); }); it('argparse script', async () => { const input = ['function _test', ' argparse h/help a/args -- $argv', ' or return', ' if set -lq _flag_help', ' echo "Usage: _test [-h|--help] [-a|--args]"', ' return', ' end', '', ' if set -lq _flag_args', '', ' end', 'end', ].join('\n'); const { rootNode } = parser.parse(input); const document = createFakeLspDocument('/tmp/foo.fish', input); const argparseNode = processNestedTree(document, rootNode); const flat = flattenNested(...argparseNode) .filter(n => n.fishKind === 'ARGPARSE'); expect(flat.length).toBe(4); }); it('for', async () => { const source = [ 'function foo --argument-names a b c d e ', ' for i in $argv', ' echo $i', ' end', 'end', ].join('\n'); const { rootNode } = parser.parse(source); const document = createFakeLspDocument('functions/foo.fish', source); const forNode = processNestedTree(document, rootNode); // console.log({ forNode: forNode?.toString() }); const flat = flattenNested(...forNode); expect(flat.length).toBe(8); const forSymbol = flat.find(n => n.fishKind === 'FOR')!; expect(forSymbol).toBeDefined(); expect(forSymbol.scope.scopeTag).toBe('local'); }); it('complete', async () => { }); it('alias', async () => { const source = 'alias foo \'echo hi\''; const document = createFakeLspDocument('functions/foo.fish', source); const { rootNode } = parser.parse(source); const aliasNode = getChildNodes(rootNode).find(n => isCommandWithName(n, 'alias'))!; const aliasSymbol = processAliasCommand(document, aliasNode).pop()!; expect(aliasSymbol).toBeDefined(); expect(aliasSymbol!.scope.scopeTag).toBe('local'); expect(aliasSymbol!.name).toEqual('foo'); const flat = flattenNested(aliasSymbol); expect(flat.length).toBe(1); expect(flat[0]!.fishKind).toBe('ALIAS'); }); it('function', async () => { const source = [ 'function foo --argument-names a b c d e \\', ' --description \'this is a description\' \\', ' --wraps \'echo\' \\', ' --inherit-variable v1 \\', ' --no-scope-shadowing', ' echo $v1', ' function bar --argument-names aaa', ' echo $aaa', ' end', 'end', ].join('\n'); const { rootNode } = parser.parse(source); const document = createFakeLspDocument('functions/foo.fish', source); const funcNode = processNestedTree(document, rootNode); // console.log({ funcNode: funcNode?.toString() }); const flat = flattenNested(...funcNode); expect(flat.length).toBe(11); expect(flat.filter(n => n.fishKind === 'FUNCTION').length).toBe(2); }); }); describe('global', () => { it('set', async () => { const source = [ 'function foo --argument-names a b c d e \\', ' --description \'this is a description\' \\', ' --wraps \'echo\' \\', ' --inherit-variable v1 \\', ' --no-scope-shadowing', ' set -gx abcde 1', ' set -gx __two 2', ' set -gx __three 3', ' function bar', ' set -gx __four 4', ' end', 'end', ].join('\n'); const { rootNode } = parser.parse(source); const document = createFakeLspDocument('functions/foo.fish', source); const funcNode = processNestedTree(document, rootNode); const flat = flattenNested(...funcNode); const funcs = flat.filter(n => n.fishKind === 'FUNCTION'); expect(funcs.length).toBe(2); expect(funcs[0]!.scope.scopeTag).toBe('global'); expect(funcs[1]!.scope.scopeTag).toBe('local'); expect(flat.length).toBe(14); expect(flat.filter(n => n.name === 'argv').length).toBe(2); // for (const item of flat) { // console.log(item.name, item.fishKind); // } }); it('read', async () => { }); it('argparse', async () => { }); it('for', async () => { }); it('alias', async () => { const source = 'alias foo \'echo hi\''; const document = createFakeLspDocument('conf.d/foo.fish', source); const { rootNode } = parser.parse(source); const aliasNode = getChildNodes(rootNode).find(n => isCommandWithName(n, 'alias'))!; const aliasSymbol = processAliasCommand(document, aliasNode).pop()!; expect(aliasSymbol).toBeDefined(); expect(aliasSymbol!.scope.scopeTag).toBe('global'); // console.log({ aliasSymbol: aliasSymbol.toString() }); }); it('complete', async () => { }); it('function', async () => { }); }); describe('skip processing', () => { it('set -q', async () => { const source = 'set -lq foo bar baz'; const { rootNode } = parser.parse(source); const document = createFakeLspDocument('config.fish', source); const setNode = processNestedTree(document, rootNode); expect(setNode.length).toBe(0); }); it('set --query', async () => { const source = 'set --query foo bar baz'; const { rootNode } = parser.parse(source); const document = createFakeLspDocument('config.fish', source); const setNode = processNestedTree(document, rootNode); expect(setNode.length).toBe(0); }); }); }); describe('test options file', () => { describe('findOptions', () => { it('Argparse findOptions()', async () => { const source = 'argparse --name foo h/help -- $argv; or return'; const { rootNode } = parser.parse(source); const argparseOptions = Array.from(Parsers.argparse.ArgparseOptions); const focusedNode = getChildNodes(rootNode).find(n => isCommandWithName(n, 'argparse'))!; const isBefore = (a: SyntaxNode, b: SyntaxNode) => a.startIndex < b.startIndex; const endStdin = focusedNode.children.find(n => isEndStdinCharacter(n))!; const search = focusedNode.childrenForFieldName('argument')!.filter(n => isBefore(n, endStdin)); const results = Parsers.options.findOptions(search, argparseOptions); // logResult(results); expect(results.found.length).toBe(1); expect(results.remaining.length).toBe(1); expect(results.unused.length).toBe(6); }); it('Set findOptions()', async () => { const source = 'set -U foo (echo \'universal var\')'; const { rootNode } = parser.parse(source); const focusedNode = getChildNodes(rootNode).find(n => isCommandWithName(n, 'set'))!; const search = Parsers.set.findSetChildren(focusedNode); const setOptions = Parsers.set.SetOptions; const results = Parsers.options.findOptions(search, setOptions); // logResult(results); expect(results.found.length).toBe(1); expect(results.remaining.length).toBe(1); expect(results.unused.length).toBe(16); }); it('Read findOptions()', async () => { const source = 'read -l foo bar baz'; const { rootNode } = parser.parse(source); const focusedNode = getChildNodes(rootNode).find(n => isCommandWithName(n, 'read'))!; const search = focusedNode.childrenForFieldName('argument')!; const readOptions = Parsers.read.ReadOptions; const results = Parsers.options.findOptions(search, readOptions); // logResult(results); expect(results.found.length).toBe(1); expect(results.remaining.length).toBe(3); expect(results.unused.length).toBe(18); }); it('Function findOptions()', async () => { const source = 'function foo --argument-names a b c d e; echo $a; end'; const { rootNode } = parser.parse(source); const focusedNode = getChildNodes(rootNode).find(n => n.type === 'function_definition')!; const search = focusedNode.childrenForFieldName('option')!; const functionOptions = Parsers.function.FunctionOptions; const results = Parsers.options.findOptions(search, functionOptions); // const opts = findOptionsSet(focusedNode.childrenForFieldName('option')!, functionOptions); // for (const n of focusedNode.childrenForFieldName('option')!) { // const opt = Option.create('-a', '--argument-names').withMultipleValues() // console.log({ // matchesValue: opt.matchesValue(n), // isSet: opt.isSet(n), // text: n.text // }); // console.log(Option.create('-a', '--argument-names').withMultipleValues().matchesValue(n), n.text); // } // logResult(results); expect(results.found.length).toBe(5); expect(results.remaining.length).toBe(0); expect(results.unused.length).toBe(5); }); }); describe('test raw equals', () => { it('equals raw long option', () => { const options = Parsers.function.FunctionOptions; const searchLongOptions: LongFlag[] = ['--argument-names', '--description', '--wraps', '--on-event', '--on-variable']; const found = options.filter(o => o.equalsRawLongOption(...searchLongOptions)); expect(searchLongOptions.length).toBe(found.length); }); it('equals raw short option', () => { const options = Parsers.function.FunctionOptions; const searchShortOptions: ShortFlag[] = ['-a', '-d', '-w', '-e', '-v']; const found = options.filter(o => o.equalsRawShortOption(...searchShortOptions)); expect(searchShortOptions.length).toBe(found.length); }); it('equals raw option', () => { const options = Parsers.function.FunctionOptions; const searchOptions: (ShortFlag | LongFlag)[] = [ '-a', '--argument-names', '-d', '--description', '-w', '--wraps', '-e', '--on-event', '-v', '--on-variable', ]; const found = options.filter(o => o.equalsRawOption(...searchOptions)); expect(found.length).toBe(5); }); }); describe('test equivalent options', () => { it('isOption()', () => { const options = Parsers.function.FunctionOptions; const searchOptions: [ShortFlag, LongFlag][] = [ ['-a', '--argument-names'], ['-d', '--description'], ['-w', '--wraps'], ['-e', '--on-event'], ['-v', '--on-variable'], ]; searchOptions.forEach(([short, long]) => { expect(options.find(o => o.isOption(short, long))).toBeDefined(); }); }); }); }); describe('show symbol details', () => { it('function foo', async () => { const source = [ 'function foo --argument-names a b c d e \\', ' --description \'this is a description\' \\', ' --wraps \'echo\' \\', ' --inherit-variable v1 \\', ' --no-scope-shadowing', ' alias ls=\'exa -1 -a --color=always\'', ' set -gx abcde 1', ' set -gx __two 2', ' set -gx __three 3', ' set -gx fish_lsp_enabled_handlers complete', ' function bar', ' argparse h/help "n/name" -- $argv', ' or return', ' set -gx __four 4', ' end', 'end', ].join('\n'); const { rootNode } = parser.parse(source); const document = createFakeLspDocument('functions/foo.fish', source); const funcNode = processNestedTree(document, rootNode); const flat = flattenNested(...funcNode); const funcs = flat.filter(n => n.fishKind === 'FUNCTION'); expect(funcs.length).toBe(2); expect(funcs.at(0)!.detail.split('\n').at(-2)!).toBe('foo a b c d e'); // -2 to skip ``` expect(funcs.at(1)!.detail.split('\n').at(-2)!).toBe('end'); // check that end is properly formatted // console.log('-'.repeat(80)); // for (const func of funcs) { // console.log(func.detail.toString()); // console.log('-'.repeat(80)); // } const aliases = flat.filter(n => n.fishKind === 'ALIAS'); // console.log('-'.repeat(80)); // for (const func of aliases) { // console.log(func.detail.toString()); // console.log('-'.repeat(80)); // } expect(aliases.at(0)!.detail.split('\n').filter(line => line === md.separator()).length).toBe(2); // console.log('-'.repeat(80)); const variables = flat.filter(n => n.kind === SymbolKind.Variable); expect(variables.length).toBe(17); // for (const variable of variables) { // console.log(variable.detail.toString()); // console.log('-'.repeat(80)); // } // const argparse = flat.filter(n => n.fishKind === 'ARGPARSE'); // for (const arg of argparse) { // console.log(arg.name, { aliases: arg.aliasedNames }); // console.log('-'.repeat(80)); // const argumentNamesOption = arg.aliasedNames // .map(n => n.slice(`_flag_`.length).replace(/_/g, '-')) // .map(n => n.length === 1 ? `${'cmd'} -${n.toString()}` : `cmd --${n.toString()}`) // .join('\n'); // console.log(argumentNamesOption); // } }); }); describe('client trees', () => { it('show simple autoloaded DocumentSymbol client tree', () => { const source = [ 'function foo --argument-names a b c d e', ' echo $a', ' echo $b', ' echo $c', ' echo $d', ' echo $e', 'end', ].join('\n'); const document = createFakeLspDocument('functions/foo.fish', source); const { rootNode } = parser.parse(source); const symbolsTree = processNestedTree(document, rootNode); const flatSymbols = flattenNested(...symbolsTree); expect(symbolsTree.length).not.toBe(flatSymbols.length); // printClientTree({log: true},...symbolsTree); const tree = printClientTree({ log: false }, ...symbolsTree); // console.log(tree.join('\n')); expect(tree.join('\n')).toBe([ 'foo', ' argv', ' a', ' b', ' c', ' d', ' e'].join('\n'), ); }); }); describe('test SyntaxNode checking', () => { describe('function names', () => { it('function_definition', () => { const source = 'function foo; echo \'inside foo\'; end'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isFunctionDefinitionName)!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('foo'); }); it('alias', () => { const source = 'alias foo \'echo hi\''; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isAliasDefinitionName)!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('foo'); }); it('alias concatenation', () => { const source = 'alias foo=\'echo hi\''; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isAliasDefinitionName)!; getNamedChildNodes(foundNode).forEach(n => { if (n.type === 'concatenation') { console.log({ text: n.text, firstChild: n.firstChild?.text, }); } }); expect(foundNode).toBeDefined(); expect(foundNode.text.split('=').at(0)!).toBe('foo'); }); }); describe('variable names', () => { it('set', () => { const source = 'set -U foo (echo \'universal var\')'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isSetVariableDefinitionName)!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('foo'); }); it('set -q', () => { const source = 'set -ql foo (echo \'universal var\')'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isSetVariableDefinitionName); expect(foundNode).toBeUndefined(); }); it('read', () => { const source = 'read -l foo bar baz'; const { rootNode } = parser.parse(source); const foundNodes = getChildNodes(rootNode).filter(ParsingDefinitionNames.isReadVariableDefinitionName)!; expect(foundNodes.length).toBe(3); expect(foundNodes.map(n => n.text)).toEqual(['foo', 'bar', 'baz']); }); it('argparse', () => { const source = 'argparse --name foo h/help -- $argv'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isArgparseVariableDefinitionName)!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('h/help'); }); it('for', () => { const source = 'for foo in $argv; echo $foo; end'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(ParsingDefinitionNames.isForVariableDefinitionName)!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('foo'); }); it('function --flags', () => { const source = 'function foo --argument-names a b c d e --description \'this is a description\' --wraps \'echo\' --inherit-variable v1 --no-scope-shadowing; end;'; const { rootNode } = parser.parse(source); const foundNodes = getChildNodes(rootNode).filter(ParsingDefinitionNames.isFunctionVariableDefinitionName)!; expect(foundNodes.map(n => n.text)).toEqual(['a', 'b', 'c', 'd', 'e', 'v1']); }); }); describe('isDefinitionName', () => { const tests = [ { input: 'function foo; echo \'inside foo\'; end', expected: ['foo'], }, { input: 'alias foo \'echo hi\'', expected: ['foo'], }, { input: 'alias foo=\'echo hi\'', expected: ['foo='], }, { input: 'set -g foo (echo \'global var\')', expected: ['foo'], }, { input: 'read -l foo bar baz', expected: ['foo', 'bar', 'baz'], }, { input: 'argparse --name foo h/help -- $argv', expected: ['h/help'], }, { input: 'for foo in $argv; echo $foo; end', expected: ['foo'], }, { input: 'function foo --argument-names a b c d e --description \'this is a description\' --wraps \'echo\' --inherit-variable v1 --no-scope-shadowing; end;', expected: ['foo', 'a', 'b', 'c', 'd', 'e', 'v1'], }, ]; tests.forEach(({ input, expected }) => { it(input, () => { const { rootNode } = parser.parse(input); const foundNodes = getChildNodes(rootNode).filter(DefinitionNodeNames.isDefinitionName)!; expect(foundNodes.map(n => n.text)).toEqual(expected); }); }); }); }); describe('source', () => { describe('isSourceCommandName()', () => { it('input with 4 sources', () => { const input = [ 'source $__fish_data_dir/config.fish --help', '. $__fish_data_dir/config.fish --help', 'source $__fish_data_dir/config.fish > /dev/null', 'thefuck --alias | source', ].join('\n'); const { rootNode } = parser.parse(input); const foundNodes = getChildNodes(rootNode).filter(isSourceCommandName); expect(foundNodes).toHaveLength(4); }); }); describe('isSourceCommandWithArgument()', () => { it('source $__fish_data_dir/config.fish --help', () => { const source = 'source $__fish_data_dir/config.fish --help'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(isSourceCommandWithArgument); expect(foundNode).toBeDefined(); }); it('echo "complete -c foo -e" | source', () => { const source = 'echo "complete -c foo -e" | source'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(isSourceCommandWithArgument); expect(foundNode).toBeUndefined(); }); }); describe('isSourcedFilename() && isExistingSourcedFilenameNode())', () => { describe('command syntax using source command: `source some_file`', () => { it('Does not exist', () => { const source = 'source __file_does_not_exist.fish'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n))!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('__file_does_not_exist.fish'); expect(isExistingSourceFilenameNode(foundNode)).toBeFalsy(); }); it('Does exist', () => { const source = 'source $__fish_data_dir/config.fish'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n))!; expect(foundNode).toBeDefined(); // console.log(foundNode.text); const expanded = SyncFileHelper.expandEnvVars(foundNode.text); console.log({ text: foundNode.text, expanded, }); expect(expanded.startsWith('$')).toBeFalsy(); expect(isExistingSourceFilenameNode(foundNode)).toBeTruthy(); }); it('Multiple arguments to source file', () => { const source = [ 'source $__fish_data_dir/config.fish --help', ].join('\n'); const { rootNode } = parser.parse(source); const foundNodes = getChildNodes(rootNode).filter(n => isSourcedFilename(n)); expect(foundNodes.length).toBe(1); foundNodes.forEach(n => { expect(isExistingSourceFilenameNode(n)).toBeTruthy(); }); }); }); describe('command syntax using dot: `. some_file`', () => { it('Does not exist', () => { const source = '. __file_does_not_exist.fish'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n))!; expect(foundNode).toBeDefined(); expect(foundNode.text).toBe('__file_does_not_exist.fish'); expect(isExistingSourceFilenameNode(foundNode)).toBeFalsy(); }); it('Does exist', () => { const source = '. $__fish_data_dir/config.fish'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n))!; expect(foundNode).toBeDefined(); expect(SyncFileHelper.expandEnvVars(foundNode.text).startsWith('$')).toBeFalsy(); expect(isExistingSourceFilenameNode(foundNode)).toBeTruthy(); }); it('Multiple arguments to source file', () => { const source = [ '. $__fish_data_dir/config.fish --help', ].join('\n'); const { rootNode } = parser.parse(source); const foundNodes = getChildNodes(rootNode).filter(n => isSourcedFilename(n)); expect(foundNodes.length).toBe(1); foundNodes.forEach(n => { expect(isExistingSourceFilenameNode(n)).toBeTruthy(); }); }); }); describe('pipe to source', () => { it("echo 'complete -c foo -e' | source", () => { const source = 'echo \'complete -c foo -e\' | source'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n)); expect(foundNode).toBeUndefined(); }); }); describe('source with redirection', () => { it('source foo.fish > /dev/null', () => { const source = 'source foo.fish > /dev/null'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n)); expect(foundNode).toBeDefined(); expect(foundNode!.text).toBe('foo.fish'); }); }); describe('find all sourced filepaths', () => { it('5 source commands, 3 filepaths', () => { const source = [ 'source $__fish_data_dir/config.fish --help', '. $__fish_data_dir/config.fish --help', 'source $__fish_data_dir/config.fish > /dev/null', 'thefuck --alias | source', 'echo "complete -c foo -e" | source', ].join('\n'); const { rootNode } = parser.parse(source); const sourceCommands = getChildNodes(rootNode).filter(n => isSourceCommandName(n)); expect(sourceCommands.length).toBe(5); const sourcedFilenames = getChildNodes(rootNode).filter(n => isSourcedFilename(n)); expect(sourcedFilenames).toHaveLength(3); }); }); }); describe('getExpandedSourcedFilenameNode()', () => { it('source $__fish_data_dir/config.fish', () => { const source = 'source $__fish_data_dir/config.fish'; const { rootNode } = parser.parse(source); const foundNode = getChildNodes(rootNode).find(n => isSourcedFilename(n))!; const expanded = getExpandedSourcedFilenameNode(foundNode); expect(expanded).toBeDefined(); }); it('for file in $HOME/.config/fish/config.fish; source $file; end', () => { const source = [ 'for file in $HOME/.config/fish/config.fish', ' source $file', ' source boo.fish', ' source ~/__foo.fish', ' source $__fish_data_dir/config.fish', ' source $__fish_data_dir/baz.fish', ' source $HOME/.config/fish/config.fish', 'end'].join('\n'); const { rootNode } = parser.parse(source); const foundNodes = getChildNodes(rootNode).filter(n => isSourceCommandArgumentName(n))!; const diagnosticNodes = [ 'boo.fish', '~/__foo.fish', '$__fish_data_dir/baz.fish', ]; const notDiagnosticNodes = [ '$file', '$HOME/.config/fish/config.fish', '$__fish_data_dir/config.fish', ]; foundNodes.forEach(n => { const isDiagnostic = Diagnostics.isSourceFilename(n); if (diagnosticNodes.includes(n.text)) { expect(isDiagnostic).toBeTruthy(); } else if (notDiagnosticNodes.includes(n.text)) { expect(isDiagnostic).toBeFalsy(); } }); }); }); }); describe('completion <--> argparse locations', () => { describe('find completions in a document', () => { it('`functions/foo.fish` | `foo --help | foo -h`', () => { const input = [ 'function foo', ' argparse -i h/help -- $argv', ' or return', ' echo hi', 'end', ].join('\n'); const document = createFakeLspDocument('functions/foo.fish', input); const { rootNode } = parser.parse(input); const symbols = flattenNested(...processNestedTree(document, rootNode)); const opts = symbols.filter(symbol => symbol.fishKind === 'ARGPARSE'); console.log({ opts: opts.map(o => o.name), }); }); it('`completions/foo.fish', () => { const input = [ 'complete -c foo -f', 'complete -c foo -s h -l help', ].join('\n'); const document = createFakeLspDocument('completions/foo.fish', input); expect(document).toBeDefined(); const { rootNode } = parser.parse(input); const matches: string[] = []; const completeCommands = getChildNodes(rootNode).filter(n => isCompletionCommandDefinition(n)); for (const completeCommand of completeCommands) { const completionSymbol = processCompletion(document, completeCommand); const firstItem = completionSymbol.pop(); matches.push(firstItem?.text || ''); } expect(matches.length).toBe(2); }); }); describe('compare symbols to completions', () => { const inputs = [ { uri: 'functions/foo.fish', source: [ 'function foo', ' argparse -i h/help -- $argv', ' or return', ' echo hi', 'end', ].join('\n'), }, { uri: 'completions/foo.fish', source: [ 'complete -c foo -f', 'complete -c foo -s h -l help', ].join('\n'), }, ]; it("compare `foo _flag_h/_flag_help` to `h/help' `{functions,completions}/foo.fish`", () => { analyzer = new Analyzer(parser); const documents = inputs.map(({ uri, source }) => { const document = createFakeLspDocument(uri, source); analyzer.analyze(document); return document; }); const completionDoc = documents.find(d => d.uri.endsWith('completions/foo.fish'))!; const functionDoc = documents.find(d => d.uri.endsWith('functions/foo.fish'))!; // console.log({ // completionDoc: completionDoc.uri, // functionDoc: functionDoc.uri, // }); expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); const argparseSymbols = analyzer.getFlatDocumentSymbols(functionDoc.uri) .filter(sym => sym.fishKind === 'ARGPARSE'); const completionSymbols = analyzer.getFlatCompletionSymbols(completionDoc.uri); expect(completionSymbols.length).toBe(2); argparseSymbols.map((symbol) => { const document = analyzer.getDocument(symbol.uri); if (document && document.getAutoloadType() === 'functions') { const equalCompletionSymbol = completionSymbols.find(completionSymbol => { return completionSymbol.equalsArgparse(symbol); }); expect(equalCompletionSymbol).toBeDefined(); return; } fail(); }); }); it('compare using `getGlobalArgparseLocations()`', async () => { // setup the analyzer analyzer = new Analyzer(parser); // setup the documents const documents = inputs.map(({ uri, source }) => { const document = createFakeLspDocument(uri, source); analyzer.analyze(document); return document; }); // get the documents so testing is easier const completionDoc = documents.find(d => d.uri.endsWith('completions/foo.fish'))!; const functionDoc = documents.find(d => d.uri.endsWith('functions/foo.fish'))!; const argparseSymbols = analyzer.getFlatDocumentSymbols(functionDoc.uri) .filter(sym => sym.fishKind === 'ARGPARSE'); const workspace = Workspace.syncCreateFromUri(completionDoc.getFilePath()!); if (!workspace) fail(); workspaceManager.add(workspace); // console.log({ // workspaces: workspaces.length, // }) // check that the argparse symbols are are defined in both files argparseSymbols.forEach(symbol => { console.log({ symbol: { name: symbol.name, kind: symbol.fishKind, uri: symbol.uri, }, 'isGlobalArgparseDefinition(analyzer, functionDoc, symbol)': isGlobalArgparseDefinition(analyzer, functionDoc, symbol), 'getGlobalArgparseLocations(analyzer, functionDoc, symbol)': getGlobalArgparseLocations(analyzer, functionDoc, symbol), completionDoc: completionDoc.uri, functionDoc: functionDoc.uri, workspace: workspace.uri, }); const locations = getGlobalArgparseLocations(analyzer, functionDoc, symbol); expect(locations.length).toBe(1); const completionSymbol = locations[0]; expect(completionSymbol).toBeDefined(); if (!completionSymbol) fail(); expect(completionSymbol.uri).toBe(completionDoc.uri); const equalCompletionSymbol = completionSymbol.uri !== functionDoc.uri; expect(equalCompletionSymbol).toBeTruthy(); }); }); }); describe.only('completion --> to argparse', () => { let workspace: LspDocument[] = []; beforeEach(async () => { parser = await initializeParser(); analyzer = new Analyzer(parser); workspace = createTestWorkspace(analyzer, { path: 'functions/foo.fish', text: [ 'function foo', ' argparse -i h/help long other-long s \'1\' -- $argv', ' or return', ' echo hi', 'end', ].join('\n'), }, { path: 'completions/foo.fish', text: [ 'complete -c foo -f -k', 'complete -c foo -s h -l help', 'complete -c foo -k -l long', 'complete -c foo -k -l other-long -d \'other long\'', 'complete -c foo -k -s s -d \'short\'', 'complete -c foo -k -s 1 -d \'1 item\'', ].join('\n'), }); }); it('completion >>(((*> function', () => { const resultOptions: CompletionSymbol[] = []; const resultArgparse: FishSymbol[] = []; workspace.forEach(doc => { console.log(doc.uri); if (doc.isFunction()) { const symbolTree = processNestedTree(doc, analyzer.getRootNode(doc.uri)!); const flatTree = flattenNested(...symbolTree); resultArgparse.push(...flatTree); } analyzer.getNodes(doc.uri).forEach(node => { const cmpSymbol = getCompletionSymbol(node); if (cmpSymbol.isNonEmpty()) { resultOptions.push(cmpSymbol); } }); }); for (const cmpSymbol of resultOptions) { const found = resultOptions.find(o => cmpSymbol.isCorrespondingOption(o)); if (!found) continue; expect(found.node?.text === 'h' || found.node?.text === 'help').toBeTruthy(); console.log({ cmpSymbol: cmpSymbol.toUsage(), found: found?.toUsage(), }); } groupCompletionSymbolsTogether(...resultOptions).forEach((group, idx) => { group.forEach(symbol => { console.log(idx, { text: symbol.text, symbol: symbol.toUsage(), }); }); }); // there is only one pair: `-h`/`--help` expect(groupCompletionSymbolsTogether(...resultOptions)).toHaveLength(5); // make _flag_h/_flag_help === -h/--help ... for (const argSymbol of resultArgparse.filter(arg => arg.fishKind === 'ARGPARSE')) { const foundOption = resultOptions.find(o => o.equalsArgparse(argSymbol) && o?.hasCommandName(argSymbol.name)); if (!foundOption) continue; console.log({ found: foundOption.toUsage(), flag: argSymbol.argparseFlagName, argparseLength: argSymbol.argparseFlagName.length, argparseParent: argSymbol.parent?.name, argSymbol: argSymbol.name, }); } }); }); }); }); ///////////////////////////////////////////////////////////////////////// // mini testing Option arrays ///////////////////////////////////////////////////////////////////////// const _cmp_options = [ Option.create('-c', '--command').withValue(), Option.create('-f', '--no-files'), Option.create('-a', '--arguments').withValue(), Option.create('-s', '--short-option').withValue(), Option.create('-l', '--long-option').withValue(), Option.create('-k', '--keep-order'), Option.create('-d', '--description').withValue(), Option.create('-x', '--exclusive'), Option.create('-r', '--require-parameter'), ]; const _fn_options = [ Option.create('-a', '--argument-names').withMultipleValues(), Option.create('-d', '--description').withValue(), Option.create('-w', '--wraps').withValue(), Option.create('-V', '--inherit-variable').withValue(), Option.create('-S', '--no-scope-shadowing'), ]; ================================================ FILE: tests/parsing-env-values.test.ts ================================================ import * as os from 'os'; /* eslint-disable @stylistic/quotes */ import { initializeParser } from '../src/parser'; import { createFakeLspDocument, setLogger } from './helpers'; // import { isLongOption, isOption, isShortOption, NodeOptionQueryText } from '../src/utils/node-types'; // import { SymbolKind } from 'vscode-languageserver'; import * as Parser from 'web-tree-sitter'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; // import { isFunctionDefinitionName } from '../src/parsing/function'; import { Analyzer } from '../src/analyze'; import { LspDocument } from '../src/document'; // import { LocalFishLspDocumentVariable } from '../src/parsing/values'; // import { config, ConfigSchema, Config, toBoolean, toNumber, getDefaultConfiguration, updateConfigValues } from '../src/config'; // import { z } from 'zod'; // import { logger } from '../src/logger'; let analyzer: Analyzer; let parser: Parser; /** * Symbolic workspace for testing */ let docs: LspDocument[] = []; let doc: LspDocument; type PrintClientTreeOpts = { log: boolean; }; describe('parsing $fish_lsp_* definitions & evaluating their values', () => { setLogger(); beforeEach(async () => { setupProcessEnvExecFile(); await setupProcessEnvExecFile(); parser = await initializeParser(); analyzer = new Analyzer(parser); }); afterEach(() => { // Reset the parser and analyzer after each test parser.delete(); docs = []; }); it('config.fish defining $fish_lsp_enabled_handlers', () => { const doc = createFakeLspDocument('config.fish', `set fish_lsp_enabled_handlers 'complete' 'hover' 'signature'`, ); analyzer.analyze(doc); expect(analyzer.getFlatDocumentSymbols(doc.uri).length).toBeGreaterThan(0); }); it('config.fish defining $fish_lsp_disabled_handlers', () => { const doc = createFakeLspDocument('config.fish', `set fish_lsp_disabled_handlers 'hover' 'signature'`, ); analyzer.analyze(doc); expect(analyzer.getFlatDocumentSymbols(doc.uri).length).toBeGreaterThan(0); }); it('config.fish defining $fish_lsp_commit_characters', () => { const doc = createFakeLspDocument('config.fish', `set fish_lsp_commit_characters '.' ',' ';'`, ); analyzer.analyze(doc); expect(analyzer.getFlatDocumentSymbols(doc.uri).length).toBeGreaterThan(0); }); it('config.fish defining $fish_lsp_log_file', () => { const doc = createFakeLspDocument('config.fish', `set fish_lsp_log_file '/tmp/fish-lsp.log'`, ); analyzer.analyze(doc); expect(analyzer.getFlatDocumentSymbols(doc.uri).length).toBeGreaterThan(0); }); // describe('general finding $fish_lsp_*', () => { // it('config.fish w/ config.** already set', () => { // console.log(JSON.stringify(config, null, 2)); // doc = createFakeLspDocument('config.fish', // `set fish_lsp_enabled_handlers 'complete'`, // `set fish_lsp_disabled_handlers 'hover' 'signature'`, // `set fish_lsp_commit_characters '.'`, // `set fish_lsp_log_file '/tmp/fish-lsp.log'`, // `set fish_lsp_log_level 'debug'`, // `set fish_lsp_all_indexed_paths '${os.homedir()}/.config/fish' '/usr/share/fish'`, // `set fish_lsp_modifiable_paths ''`, // `set fish_lsp_diagnostic_disable_error_codes '2002' 4001`, // `set fish_lsp_enable_experimental_diagnostics true`, // `set fish_lsp_max_background_files 10`, // 'set fish_lsp_show_client_popups true', // 'set -eg fish_lsp_single_workspace_support', // 'set fish_lsp_single_workspace_support true', // ); // analyzer.analyze(doc); // const symbols = analyzer.getFlatDocumentSymbols(doc.uri); // const fishLspSymbols = symbols.filter(s => s.kind === SymbolKind.Variable && s.name.startsWith('fish_lsp_')); // // const newConfig: Record = {} as Record; // const configCopy: Config = Object.assign({}, config); // // for (const s of fishLspSymbols) { // const configKey = Config.getEnvVariableKey(s.name); // if (!configKey) { // // configCopy[s.name] = ; // continue; // } // // if (LocalFishLspDocumentVariable.hasEraseFlag(s)) { // const schemaType = ConfigSchema.shape[configKey as keyof z.infer]; // // (config[configKey] as any) = schemaType.parse(schemaType._def.defaultValue()); // continue; // } // // const shellValues = LocalFishLspDocumentVariable.findValueNodes(s).map(s => LocalFishLspDocumentVariable.nodeToShellValue(s)); // // if (shellValues.length > 0) { // if (shellValues.length === 1) { // const value = shellValues[0]; // if (toBoolean(value)) { // newConfig[configKey] = toBoolean(value); // continue; // } // if (toNumber(value)) { // newConfig[configKey] = toNumber(value); // continue; // } // newConfig[configKey] = value; // continue; // } else { // if (shellValues.every(v => !!toNumber(v))) { // (newConfig[configKey] as any) = shellValues.map(v => toNumber(v)); // } else if (shellValues.every(v => toBoolean(v))) { // (newConfig[configKey] as any) = shellValues.map(v => toBoolean(v)); // } else { // (newConfig[configKey] as any) = shellValues; // } // } // } // } // Object.assign(config, updateConfigValues(configCopy, newConfig)); // // console.log(); // console.log(config); // // Object.assign(config, getDefaultConfiguration()); // console.log(config); // }); // }); }); ================================================ FILE: tests/parsing-export-defintions.test.ts ================================================ import { Parsers, Option, ParsingDefinitionNames, DefinitionNodeNames } from '../src/parsing/barrel'; import { execAsyncF } from '../src/utils/exec'; import { initializeParser } from '../src/parser'; import { createFakeLspDocument, createTestWorkspace, setLogger } from './helpers'; // import { isLongOption, isOption, isShortOption, NodeOptionQueryText } from '../src/utils/node-types'; import * as Parser from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; import { getChildNodes, getNamedChildNodes } from '../src/utils/tree-sitter'; import { FishSymbol, processNestedTree } from '../src/parsing/symbol'; import { processAliasCommand } from '../src/parsing/alias'; import { flattenNested } from '../src/utils/flatten'; import { isCommandWithName, isEndStdinCharacter, isFunctionDefinition } from '../src/utils/node-types'; import { LongFlag, ShortFlag } from '../src/parsing/options'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { SymbolKind } from 'vscode-languageserver'; import { md } from '../src/utils/markdown-builder'; // import { isFunctionDefinitionName } from '../src/parsing/function'; import { getExpandedSourcedFilenameNode, isExistingSourceFilenameNode, isSourcedFilename, isSourceCommandName, isSourceCommandWithArgument, isSourceCommandArgumentName } from '../src/parsing/source'; import { SyncFileHelper } from '../src/utils/file-operations'; import * as Diagnostics from '../src/diagnostics/node-types'; import { Analyzer } from '../src/analyze'; import { groupCompletionSymbolsTogether, isCompletionCommandDefinition, getCompletionSymbol, processCompletion, CompletionSymbol } from '../src/parsing/complete'; import { getGlobalArgparseLocations, isGlobalArgparseDefinition } from '../src/parsing/argparse'; import { Workspace } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { LspDocument } from '../src/document'; import { buildExportDetail, extractExportVariable, findVariableDefinitionNameNode, isExportDefinition, isExportVariableDefinitionName, processExportCommand } from '../src/parsing/export'; let analyzer: Analyzer; let parser: Parser; type PrintClientTreeOpts = { log: boolean; }; function printClientTree( opts: PrintClientTreeOpts = { log: true }, ...symbols: FishSymbol[] ): string[] { const result: string[] = []; function logAtLevel(indent = '', ...remainingSymbols: FishSymbol[]) { const newResult: string[] = []; remainingSymbols.forEach(n => { if (opts.log) { console.log(`${indent}${n.name} --- ${n.fishKind} --- ${n.scope.scopeTag} --- ${n.scope.scopeNode.firstNamedChild?.text}`); } newResult.push(`${indent}${n.name}`); newResult.push(...logAtLevel(indent + ' ', ...n.children)); }); return newResult; } result.push(...logAtLevel('', ...symbols)); return result; } let text = ''; let rootNode: SyntaxNode; let doc: LspDocument; describe('parsing `export` variable defs', () => { setLogger(); beforeEach(async () => { setupProcessEnvExecFile(); parser = await initializeParser(); await setupProcessEnvExecFile(); }); describe('test checking functions', () => { describe('(SyntaxNode) => boolean', () => { beforeEach(() => { parser.reset(); text = [ 'export foo=bar', 'export baz="b a z"', ].join('\n'); doc = createFakeLspDocument('functions/test.fish', text); rootNode = parser.parse(text).rootNode; }); it('isExportDefinition', () => { const results = getChildNodes(rootNode).filter(c => isExportDefinition(c)); expect(results.length).toBe(2); }); it('isExportVariableDefinitionName', () => { const results = getChildNodes(rootNode).filter(c => isExportVariableDefinitionName(c)); expect(results.length).toBe(2); console.log('results', results.map(r => r.text)); }); }); describe('extractExportVariable', () => { beforeEach(() => { parser.reset(); text = [ 'export foo=bar', 'export baz=\'b a z\'', 'export qux="q u x"', 'export quux=(q u u x)', ].join('\n'); doc = createFakeLspDocument('functions/test.fish', text); rootNode = parser.parse(text).rootNode; }); it('should extract export variable', () => { const results = getChildNodes(rootNode).filter(c => isExportVariableDefinitionName(c)); expect(results.length).toBe(4); const varDefNode = results.at(0) as SyntaxNode; const varInfo = extractExportVariable(varDefNode); expect(varInfo).toBeDefined(); if (varInfo) { expect(varInfo.name).toBe('foo'); expect(varInfo.value).toBe('bar'); console.log({ name: varInfo.name, value: varInfo.value, start: varInfo.nameRange.start, end: varInfo.nameRange.end, }); expect(varInfo.name).toBe('foo'); expect(varInfo.value).toBe('bar'); expect(varInfo.nameRange).toBeDefined(); expect(varInfo.nameRange.start.line).toBe(0); expect(varInfo.nameRange.end.line).toBe(0); } }); it('should extract export variable with spaces', () => { const results = getChildNodes(rootNode).filter(c => isExportVariableDefinitionName(c)); expect(results.length).toBe(4); // const varFoo = results.at(0); // const varBaz = results.at(1); // const varQux = results.at(2); results.forEach((varDefNode, index) => { const extractedVarInfo = extractExportVariable(varDefNode); expect(extractedVarInfo).toBeDefined(); console.log({ index, ...extractedVarInfo, }); }); }); it('show details', () => { const nodes = rootNode.descendantsOfType('command').filter(c => c.firstChild && c.firstNamedChild?.text === 'export'); const result: FishSymbol[] = []; nodes.forEach((node, index) => { const symbol = processExportCommand(doc, node).at(0); if (!symbol) { return; } result.push(symbol); console.log({ index, symbol: { name: symbol.name, scope: symbol.scope.scopeTag, focusedNode: symbol.focusedNode.text, selectionRange: [symbol.selectionRange.start.line, symbol.selectionRange.start.character, symbol.selectionRange.end.line, symbol.selectionRange.end.character], detail: symbol.detail, }, }); }); expect(result).toHaveLength(4); }); it('processTree', () => { const nestedTree = processNestedTree(doc, rootNode); const symbols = flattenNested(...nestedTree); expect(symbols).toHaveLength(4); }); }); }); }); ================================================ FILE: tests/parsing-function-with-event.test.ts ================================================ import { createTestWorkspace, fail, setLogger, TestLspDocument } from './helpers'; import { SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from '../src/parser'; import { Analyzer, analyzer } from '../src/analyze'; import { FishSymbol } from '../src/parsing/symbol'; import { LspDocument } from '../src/document'; import { flattenNested } from '../src/utils/flatten'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { ErrorCodes } from '../src/diagnostics/error-codes'; import { config } from '../src/config'; import FishServer from '../src/server'; const inputDocs: TestLspDocument[] = []; let documents: LspDocument[] = []; describe('FishSymbol parsing functions tests', () => { setLogger(); beforeAll(async () => { await Analyzer.initialize(); config.fish_lsp_diagnostic_disable_error_codes = [ErrorCodes.requireAutloadedFunctionHasDescription]; }); describe('initialized', () => { it('should initialize the parser', async () => { const parser = await initializeParser(); expect(parser).toBeDefined(); }); it('should have a valid analyzer instance', async () => { expect(analyzer).toBeInstanceOf(Analyzer); }); }); describe('analyze workspace 1: `function`', () => { const inputDocs = [ { path: 'functions/fish_function.fish', text: 'function my_function; echo "Hello, World!"; end', }, { path: 'functions/another_function.fish', text: 'function another_function --on-event fish_prompt; echo "This is another function"; end', }, { path: 'config.fish', text: '', }, ]; beforeEach(async () => { documents = createTestWorkspace(analyzer, ...inputDocs); }); it('should analyze a simple function definition', async () => { const config = documents.find(doc => doc.path.endsWith('config.fish'))!; const hookFunction = documents.find(doc => doc.path.endsWith('functions/another_function.fish'))!; if (!hookFunction || !config) fail(); expect(config).toBeDefined(); expect(hookFunction).toBeDefined(); const configCached = analyzer.analyze(config); const hookFunctionCached = analyzer.analyze(hookFunction); expect(configCached).toBeDefined(); expect(hookFunctionCached).toBeDefined(); const functionSymbol = flattenNested(...hookFunctionCached.documentSymbols).find(s => s.isFunction()); expect(functionSymbol).toBeDefined(); console.log('functionSymbol?.hasEventHook(): ', functionSymbol?.hasEventHook()); expect(functionSymbol?.hasEventHook()).toBeTruthy(); // expect(functionSymbol?.isAutoloaded()).toBeDefined(); }); }); describe('analyze workspace 2: `abbr`', () => { const inputDocs = [ { path: 'conf.d/abbreviations.fish', text: [ 'if status is-interactive', ' function git_quick_stash', ' string join \' \' -- git stash push -a -m "chore: $(date +%Y-%m-%dT%H:%M:%S)"', ' end', ' abbr -a gstq --function git_quick_stash', 'end', ].join('\n'), }, { path: 'config.fish', text: '', }, ]; beforeEach(async () => { documents = createTestWorkspace(analyzer, ...inputDocs); await FishServer.setupForTestUtilities(); }); it('should analyze a simple function definition', async () => { const config = documents.find(doc => doc.path.endsWith('config.fish'))!; const functionDoc = documents.find(doc => doc.path.endsWith('conf.d/abbreviations.fish'))!; if (!functionDoc || !config) fail(); expect(config).toBeDefined(); expect(functionDoc).toBeDefined(); const configCached = analyzer.analyze(config); const functionCached = analyzer.analyze(functionDoc); expect(configCached).toBeDefined(); expect(functionCached).toBeDefined(); const functionSymbol = flattenNested(...functionCached.documentSymbols).find(s => s.isFunction()); expect(functionSymbol).toBeDefined(); expect(functionSymbol?.isFunction()).toBeTruthy(); const diagnostics = await getDiagnosticsAsync(functionCached.root!, functionCached.document); console.log({ diagnostics: diagnostics.map(d => ({ code: d.code, message: d.message, })), }); expect(diagnostics.length).toBe(0); }); }); describe('analyze workspace 3: `bind`', () => { const inputDocs = [ { path: 'conf.d/bindings.fish', text: [ 'function used_bindings', ' echo \'This keybind is used\'', 'end', 'if status is-interactive', ' function down-or-nextd-or-forward-word -d "if in completion mode(pager), then move down, otherwise, nextd-or-forward-word"', ' # if the pager is not visible, then execute the nextd-or-forward-word', ' # function', ' if not commandline --paging-mode; and not commandline --search-mode', ' commandline -f nextd-or-forward-word', ' return', ' # if the pager is visible, then move down one item', ' else', ' commandline -f down-line', ' return', ' end', ' end', ' function unused-keybind', ' echo \'This keybind is not used\'', ' end', ' function fish_user_key_bindings', ' bind ctrl-j down-or-nextd-or-forward-word', ' bind ctrl-l used_bindings', ' end', 'end', ].join('\n'), }, { path: 'config.fish', text: 'fish_user_key_bindings', }, ]; beforeEach(async () => { documents = createTestWorkspace(analyzer, ...inputDocs); }); it('should analyze a simple function definition', async () => { const config = documents.find(doc => doc.path.endsWith('config.fish'))!; const bindDoc = documents.find(doc => doc.path.endsWith('conf.d/bindings.fish'))!; if (!bindDoc || !config) fail(); expect(config).toBeDefined(); expect(bindDoc).toBeDefined(); const configCached = analyzer.analyze(config); const bindCached = analyzer.analyze(bindDoc); expect(configCached).toBeDefined(); expect(bindCached).toBeDefined(); const bindSymbol = flattenNested(...bindCached.documentSymbols).find(s => s.isFunction() && s.name === 'fish_user_key_bindings'); expect(bindSymbol).toBeDefined(); expect(bindSymbol?.isFunction()).toBeTruthy(); const diagnostics = await getDiagnosticsAsync(bindCached.root!, bindCached.document); expect(diagnostics.length).toBe(0); diagnostics.forEach((d, idx) => { console.log({ idx, code: d.code, message: d.message, severity: d.severity, data: { node: d.data.node.text, }, range: d.range, source: d.source, }); }); }); }); }); ================================================ FILE: tests/parsing-indent-comments.test.ts ================================================ import { INDENT_COMMENT_REGEX, isIndentComment, parseIndentComment, processIndentComments, getEnabledIndentRanges } from '../src/parsing/comments'; import { initializeParser } from '../src/parser'; import * as Parser from 'web-tree-sitter'; import { getChildNodes } from '../src/utils/tree-sitter'; import { setLogger } from './helpers'; import { LspDocument } from '../src/document'; import { TestWorkspace } from './test-workspace-utils'; let parser: Parser; describe('Indent Comments Parsing', () => { setLogger( async () => { parser = await initializeParser(); }, async () => { parser.reset(); }, ); describe('INDENT_COMMENT_REGEX', () => { it('should match valid fish_indent comments', () => { const validComments = [ '# @fish_indent: off', '# @fish_indent: on', '# @fish_indent: off', // Extra space after # '# @fish_indent: on', // Multiple spaces after # '# @fish_indent: ', // Space after colon but no value '# @fish_indent', // No colon (should default to on) ]; validComments.forEach(comment => { expect(INDENT_COMMENT_REGEX.test(comment.trim())).toBe(true); }); }); it('should not match invalid fish_indent comments', () => { const invalidComments = [ '# @fish_indent: invalid', // Invalid value '# @fish_indent: OFF', // Wrong case '# @fish_indent: On', // Wrong case '@fish_indent: off', // Missing # '# fish_indent: off', // Missing @ '# @fish_format: off', // Wrong command 'echo # @fish_indent: off', // Not at start of line content ]; invalidComments.forEach(comment => { expect(INDENT_COMMENT_REGEX.test(comment.trim())).toBe(false); }); }); it('should extract correct values from valid comments', () => { const tests = [ { comment: '# @fish_indent: off', expected: 'off' }, { comment: '# @fish_indent: on', expected: 'on' }, { comment: '# @fish_indent: off', expected: 'off' }, { comment: '# @fish_indent: ', expected: undefined }, // No value specified (space after colon) { comment: '# @fish_indent', expected: undefined }, // No colon at all ]; tests.forEach(({ comment, expected }) => { const match = comment.trim().match(INDENT_COMMENT_REGEX); expect(match).toBeTruthy(); expect(match![1]).toBe(expected); }); }); }); describe('isIndentComment', () => { it('should identify indent comments correctly', () => { const fishCode = ` # Regular comment # @fish_indent: off echo "hello world" # @fish_indent: on function test echo "formatted" end `; const tree = parser.parse(fishCode); const allNodes = getChildNodes(tree.rootNode); const commentNodes = allNodes.filter(node => node.type === 'comment'); expect(commentNodes.length).toBe(3); expect(isIndentComment(commentNodes[0])).toBe(false); // Regular comment expect(isIndentComment(commentNodes[1])).toBe(true); // @fish_indent: off expect(isIndentComment(commentNodes[2])).toBe(true); // @fish_indent: on }); }); describe('parseIndentComment', () => { it('should parse indent comments correctly', () => { const fishCode = ` # @fish_indent: off echo "hello" # @fish_indent: on echo "world" `; const tree = parser.parse(fishCode); const allNodes = getChildNodes(tree.rootNode); const commentNodes = allNodes.filter(node => node.type === 'comment'); const offComment = parseIndentComment(commentNodes[0]); const onComment = parseIndentComment(commentNodes[1]); expect(offComment).toBeTruthy(); expect(offComment!.indent).toBe('off'); expect(offComment!.line).toBe(commentNodes[0].startPosition.row); expect(offComment!.node).toBe(commentNodes[0]); expect(onComment).toBeTruthy(); expect(onComment!.indent).toBe('on'); expect(onComment!.line).toBe(commentNodes[1].startPosition.row); expect(onComment!.node).toBe(commentNodes[1]); }); it('should return null for non-indent comments', () => { const fishCode = ` # Regular comment echo "hello" `; const tree = parser.parse(fishCode); const allNodes = getChildNodes(tree.rootNode); const commentNodes = allNodes.filter(node => node.type === 'comment'); const result = parseIndentComment(commentNodes[0]); expect(result).toBe(null); }); it('should default to "on" when no value is specified', () => { const fishCode = '# @fish_indent'; const tree = parser.parse(fishCode); const commentNode = getChildNodes(tree.rootNode).find(node => node.type === 'comment'); expect(commentNode).toBeTruthy(); if (commentNode) { const result = parseIndentComment(commentNode); expect(result).toBeTruthy(); expect(result!.indent).toBe('on'); } else { throw new Error('Comment node not found in tree'); } }); }); describe('processIndentComments', () => { it('should find all indent comments in document', () => { const fishCode = ` # Regular comment echo "start" # @fish_indent: off echo "unformatted code" echo "still unformatted" # @fish_indent: on echo "formatted again" # Another regular comment function test # @fish_indent: off echo "local disable" # @fish_indent: on end `; const tree = parser.parse(fishCode); const indentComments = processIndentComments(tree.rootNode); expect(indentComments).toHaveLength(4); expect(indentComments[0].indent).toBe('off'); expect(indentComments[1].indent).toBe('on'); expect(indentComments[2].indent).toBe('off'); expect(indentComments[3].indent).toBe('on'); }); it('should return empty array when no indent comments exist', () => { const fishCode = ` # Regular comment echo "hello" function test echo "world" end `; const tree = parser.parse(fishCode); const indentComments = processIndentComments(tree.rootNode); expect(indentComments).toHaveLength(0); }); it('should preserve line numbers correctly', () => { const fishCode = `# @fish_indent: off echo "line 1" # @fish_indent: on`; const tree = parser.parse(fishCode); const indentComments = processIndentComments(tree.rootNode); expect(indentComments).toHaveLength(2); expect(indentComments[0].line).toBe(0); // First line expect(indentComments[1].line).toBe(2); // Third line }); }); describe('getEnabledIndentRanges', () => { it('should return full document formatting when no indent comments exist', () => { const content = `echo "hello world" function test echo "formatted" end`; const workspace = TestWorkspace.createSingle(content).initialize(); const doc = workspace.focusedDocument; const tree = parser.parse(content); const result = getEnabledIndentRanges(doc, tree.rootNode); expect(result.fullDocumentFormatting).toBe(true); expect(result.formatRanges).toHaveLength(1); }); it('should handle single off/on pair correctly', () => { const content = `echo "start" # @fish_indent: off echo "unformatted" echo "still unformatted" # @fish_indent: on echo "formatted again"`; const workspace = TestWorkspace.createSingle(content).initialize(); const doc = workspace.focusedDocument; const tree = parser.parse(content); const result = getEnabledIndentRanges(doc, tree.rootNode); expect(result.fullDocumentFormatting).toBe(false); expect(result.formatRanges).toHaveLength(2); expect(result.formatRanges[0]).toEqual({ start: 0, end: 0 }); // First line expect(result.formatRanges[1]).toEqual({ start: 5, end: 5 }); // Last line }); it('should handle multiple off/on pairs', () => { const content = `echo "line 0" echo "line 1" # @fish_indent: off echo "line 3 - unformatted" echo "line 4 - unformatted" # @fish_indent: on echo "line 6 - formatted" echo "line 7 - formatted" # @fish_indent: off echo "line 9 - unformatted" # @fish_indent: on echo "line 11 - formatted"`; const workspace = TestWorkspace.createSingle(content).initialize(); const doc = workspace.focusedDocument; const tree = parser.parse(content); const result = getEnabledIndentRanges(doc, tree.rootNode); expect(result.fullDocumentFormatting).toBe(false); expect(result.formatRanges).toHaveLength(3); expect(result.formatRanges[0]).toEqual({ start: 0, end: 1 }); // Lines 0-1 expect(result.formatRanges[1]).toEqual({ start: 6, end: 7 }); // Lines 6-7 expect(result.formatRanges[2]).toEqual({ start: 11, end: 11 }); // Line 11 }); it('should handle document starting with off', () => { const content = `# @fish_indent: off echo "unformatted" # @fish_indent: on echo "formatted"`; const workspace = TestWorkspace.createSingle(content).initialize(); const doc = workspace.focusedDocument; const tree = parser.parse(content); const result = getEnabledIndentRanges(doc, tree.rootNode); expect(result.fullDocumentFormatting).toBe(false); expect(result.formatRanges).toHaveLength(1); expect(result.formatRanges[0]).toEqual({ start: 3, end: 3 }); // Last line only }); it('should handle document ending with off', () => { const content = `echo "formatted" echo "also formatted" # @fish_indent: off echo "unformatted"`; const workspace = TestWorkspace.createSingle(content).initialize(); const doc = workspace.focusedDocument; const tree = parser.parse(content); const result = getEnabledIndentRanges(doc, tree.rootNode); expect(result.fullDocumentFormatting).toBe(false); expect(result.formatRanges).toHaveLength(1); expect(result.formatRanges[0]).toEqual({ start: 0, end: 1 }); // First two lines }); it('should handle nested off/on comments correctly', () => { const content = `echo "start" function test # @fish_indent: off echo "unformatted inside function" # @fish_indent: on echo "formatted inside function" end echo "end"`; const workspace = TestWorkspace.createSingle(content).initialize(); const doc = workspace.focusedDocument; const tree = parser.parse(content); const result = getEnabledIndentRanges(doc, tree.rootNode); expect(result.fullDocumentFormatting).toBe(false); expect(result.formatRanges).toHaveLength(2); expect(result.formatRanges[0]).toEqual({ start: 0, end: 1 }); // Lines 0-1 expect(result.formatRanges[1]).toEqual({ start: 5, end: 7 }); // Lines 5-7 }); }); }); ================================================ FILE: tests/parsing-string-value.test.ts ================================================ /** * Unit tests for `FishString` (src/parsing/string.ts) * * Verifies that every fish-shell surface representation of the string `mas` * is reduced to the bare value `"mas"` by `FishString.fromNode` and `FishString.fromText`. * * Input forms tested (from issue #140): * mas – plain unquoted word (node type: word) * 'mas' – single-quoted string (node type: single_quote_string) * "mas" – double-quoted string (node type: double_quote_string) * \mas – backslash before first char (node type: concatenation) * \ma\s – backslash before first & last (node type: concatenation) * ma\s – backslash before last char (node type: concatenation) * * @see https://github.com/ndonfris/fish-lsp/issues/140 */ import Parser from 'web-tree-sitter'; import { initializeParser } from '../src/parser'; import { FishString } from '../src/parsing/string'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- let parser: Parser; /** * Returns the SyntaxNode for the argument that immediately follows `-c` in * `complete -c -f`. */ function getCommandArgNode(input: string): Parser.SyntaxNode { const source = `complete -c ${input} -f`; const tree = parser.parse(source); const commandNode = tree.rootNode.children.find( (n: Parser.SyntaxNode) => n.type === 'command', ); if (!commandNode) throw new Error(`No command node found in: ${source}`); const children = commandNode.children; const dashCIdx = children.findIndex((c: Parser.SyntaxNode) => c.text === '-c'); if (dashCIdx === -1) throw new Error(`No -c flag found in: ${source}`); const argNode = children[dashCIdx + 1]; if (!argNode) throw new Error(`No argument after -c in: ${source}`); return argNode; } // --------------------------------------------------------------------------- // FishString.fromNode // --------------------------------------------------------------------------- describe('FishString.fromNode – issue #140 input cases', () => { beforeAll(async () => { parser = await initializeParser(); }); const cases: { input: string; description: string; }[] = [ { input: 'mas', description: 'unquoted word' }, { input: "'mas'", description: 'single-quoted string' }, { input: '"mas"', description: 'double-quoted string' }, { input: '\\mas', description: 'backslash before first character' }, { input: '\\ma\\s', description: 'backslash before first and last chars' }, { input: 'ma\\s', description: 'backslash before last character' }, ]; for (const { input, description } of cases) { it(`FishString.fromNode("${input}") === "mas" [${description}]`, () => { const node = getCommandArgNode(input); expect(FishString.fromNode(node)).toBe('mas'); }); } }); // --------------------------------------------------------------------------- // FishString.fromText – string-only variant (no SyntaxNode needed) // --------------------------------------------------------------------------- describe('FishString.fromText – issue #140 input cases (string-only variant)', () => { const cases: { input: string; description: string; }[] = [ { input: 'mas', description: 'unquoted word' }, { input: "'mas'", description: 'single-quoted string' }, { input: '"mas"', description: 'double-quoted string' }, { input: '\\mas', description: 'backslash before first character' }, { input: '\\ma\\s', description: 'backslash before first and last chars' }, { input: 'ma\\s', description: 'backslash before last character' }, ]; for (const { input, description } of cases) { it(`FishString.fromText("${input}") === "mas" [${description}]`, () => { expect(FishString.fromText(input)).toBe('mas'); }); } it('resolves \\n to newline', () => { expect(FishString.fromText('\\n')).toBe('\n'); }); it('resolves \\t to tab', () => { expect(FishString.fromText('\\t')).toBe('\t'); }); it('resolves \\\\ to a single backslash', () => { expect(FishString.fromText('\\\\')).toBe('\\'); }); it('strips single quotes from quoted string', () => { expect(FishString.fromText("'hello world'")).toBe('hello world'); }); it('strips double quotes from quoted string', () => { expect(FishString.fromText('"hello world"')).toBe('hello world'); }); }); // --------------------------------------------------------------------------- // FishString.parse – convenience overload (SyntaxNode | string) // --------------------------------------------------------------------------- describe('FishString.parse – dispatches to fromNode or fromText based on input type', () => { beforeAll(async () => { parser = await initializeParser(); }); it('accepts a plain string and strips single quotes', () => { expect(FishString.parse("'mas'")).toBe('mas'); }); it('accepts a plain string and strips double quotes', () => { expect(FishString.parse('"mas"')).toBe('mas'); }); it('accepts a plain string and resolves escape sequences', () => { expect(FishString.parse('\\mas')).toBe('mas'); }); it('accepts a plain string unquoted — returns as-is', () => { expect(FishString.parse('mas')).toBe('mas'); }); it('accepts a SyntaxNode (word) and returns its text', () => { const node = getCommandArgNode('mas'); expect(FishString.parse(node)).toBe('mas'); }); it('accepts a SyntaxNode (single_quote_string) and strips quotes', () => { const node = getCommandArgNode("'mas'"); expect(FishString.parse(node)).toBe('mas'); }); it('accepts a SyntaxNode (concatenation) and resolves escapes', () => { const node = getCommandArgNode('\\mas'); expect(FishString.parse(node)).toBe('mas'); }); it('produces the same result as fromText when given a string', () => { const inputs = ['mas', "'mas'", '"mas"', '\\mas', '\\ma\\s', 'ma\\s']; for (const input of inputs) { expect(FishString.parse(input)).toBe(FishString.fromText(input)); } }); it('produces the same result as fromNode when given a SyntaxNode', () => { const inputs = ['mas', "'mas'", '"mas"', '\\mas', '\\ma\\s', 'ma\\s']; for (const input of inputs) { const node = getCommandArgNode(input); expect(FishString.parse(node)).toBe(FishString.fromNode(node)); } }); }); ================================================ FILE: tests/process-env.test.ts ================================================ import * as Parser from 'web-tree-sitter'; import { env, EnvManager } from '../src/utils/env-manager'; import { setupProcessEnvExecFile, AutoloadedPathVariables } from '../src/utils/process-env'; import { createFakeLspDocument, setLogger } from './helpers'; import { initializeParser } from '../src/parser'; import { getChildNodes } from '../src/utils/tree-sitter'; import { isVariableDefinitionName } from '../src/utils/node-types'; import { config } from '../src/config'; import { Analyzer } from '../src/analyze'; import { LocalFishLspDocumentVariable } from '../src/parsing/values'; import * as os from 'os'; import { FishUriWorkspace } from '../src/utils/workspace'; import { pathToUri } from '../src/utils/translation'; describe('setting up process-env', () => { setLogger(); beforeEach(async () => { env.clear(); await setupProcessEnvExecFile(); }); describe('envManager', () => { it('get EMPTY STRING', () => { // console.log('EMPTY STR ""', env.get('')); expect(env.get('')).toBeUndefined(); // console.log('EMPTY STR " "', env.get(' ')); expect(env.get(' ')).toBeUndefined(); // console.log('EMPTY STR ""', env.getAsArray('')); expect(env.getAsArray('')).toEqual([]); // console.log('EMPTY STR " "', env.getAsArray(' ')); expect(env.getAsArray(' ')).toEqual([]); }); it('get(process.env.NODE_ENV)', () => { // console.log('NODE_ENV', env.get('NODE_ENV')); expect(env.get('NODE_ENV')).toBe('test'); }); it('getAsArray(AutloadedPathVariables.all())', () => { AutoloadedPathVariables.all().forEach((variable) => { // console.log(`${variable}:`, env.getAsArray(variable)); expect(Array.isArray(env.getAsArray(variable))).toBeTruthy(); }); }); it('getAsArray(process.env)', () => { Object.keys(process.env).forEach((variable) => { // console.log(`${variable}:`, env.getAsArray(variable)); expect(Array.isArray(env.getAsArray(variable))).toBeTruthy(); }); }); it('getAsArray(fish_lsp_all_indexed_paths)', () => { env.set('fish_lsp_all_indexed_paths', '/usr/share/fish /usr/local/share/fish $HOME/.config/fish'); // console.log('fish_lsp_all_indexed_paths', env.getAsArray('fish_lsp_all_indexed_paths')); expect(env.getAsArray('fish_lsp_all_indexed_paths').length).toEqual(3); }); it('find keys where value is used', () => { env.set('fish_lsp_all_indexed_paths', '/usr/share/fish /usr/local/share/fish $HOME/.config/fish'); }); it('getProcessEnv()', () => { // console.log('process.env', env.getProcessEnv()); expect(env.processEnv).toEqual(process.env); }); }); describe('keys', () => { it('process.env', () => { // console.log(env.getProcessEnvKeys().length) expect(env.getProcessEnvKeys().length).toBeGreaterThan(0); expect(env.getProcessEnvKeys().length).toBeGreaterThanOrEqual(6); }); it('envManager', () => { // console.log(env.getAutoloadedKeys().length) expect(env.getAutoloadedKeys().length).toBeGreaterThan(0); expect(env.getAutoloadedKeys().length).toBeGreaterThanOrEqual(14); }); it('allKeys', () => { expect(env.keys.length).toEqual(20); // env.keys.forEach((key, idx) => { // console.log(`${idx+1}. ${key}:`, env.getAsArray(key).slice(0, 2).join(', ').slice(0, 50)); // }) // console.log('autoloaded', env.getAutoloadedKeys().length); // env.getAutoloadedKeys().forEach((key, idx) => { // console.log(`${idx+1}. ${key}:`, env.getAsArray(key).slice(0, 2).join(', ').slice(0, 50)); // }) // console.log('process.env', env.getProcessEnvKeys().length); // env.getProcessEnvKeys().forEach((key, idx) => { // console.log(`${idx+1}. ${key}:`, env.getAsArray(key).slice(0, 2).join(', ').slice(0, 50)); // }) // console.log('all', env.keys.length); // env.keys.forEach((key, idx) => { // console.log(`${idx+1}. ${key}:`, env.getAsArray(key).slice(0, 2).join(', ').slice(0, 50)); // }) // console.log('entries') // env.entries.forEach(([key, value], idx) => { // console.log(`${idx+1}. ${key}: ${value?.slice(0, 50) || ''}`); // }) expect(env.keys.length).toEqual(env.processEnvKeys.size + env.autoloadedKeys.size); }); }); describe('has/includes', () => { it('has(process.env)', () => { expect(env.has('NODE_ENV')).toBeTruthy(); }); it('has(autoloaded)', () => { expect(env.has('fish_user_paths')).toBeTruthy(); }); it('isAutoloaded', () => { expect(env.isAutoloaded('fish_user_paths')).toBeTruthy(); }); it('isProcessEnv', () => { expect(env.isProcessEnv('NODE_ENV')).toBeTruthy(); }); it('isArray', () => { expect(env.isArray('fish_user_paths')).toBeTruthy(); }); it('entry get type', () => { env.entries.forEach(([key, value]) => { if (env.isAutoloaded(key)) { expect(Array.isArray(env.getAsArray(key))).toBeTruthy(); if (EnvManager.isArrayValue(value)) { expect(env.isArray(key)).toBeTruthy(); } } else if (env.isProcessEnv(key)) { expect(typeof value).toBe('string'); } else { fail(); } }); }); }); describe('token parser', () => { it('parsePathVariable', () => { const value = '/path/bin:/path/to/bin:/usr/share/bin'; const result = env.parser().parsePathVariable(value); expect(result).toEqual(['/path/bin', '/path/to/bin', '/usr/share/bin']); }); it('parseSpaceSeparatedWithQuotes', () => { const value = 'one two three "four five" six "seven eight"'; const result = env.parser().parseSpaceSeparatedWithQuotes(value); expect(result).toEqual(['one', 'two', 'three', 'four five', 'six', 'seven eight']); }); it('getAtIndex', () => { const value = '/path/bin:/path/to/bin:/usr/share/bin'; const result = env.parser().parsePathVariable(value); expect(env.parser().getAtIndex(result, 1)).toEqual('/path/bin'); expect(env.parser().getAtIndex(result, 2)).toEqual('/path/to/bin'); expect(env.parser().getAtIndex(result, 3)).toEqual('/usr/share/bin'); expect(env.parser().getAtIndex(result, 4)).toBeUndefined(); expect(env.parser().getAtIndex(result, 0)).toBeUndefined(); }); describe('parsing tokens `var_{1,2,3,4,5}`', () => { // Test examples const examples = [ { name: 'var_1', input: "'index 1' 'index 2' 'index 3'", output: ['index 1', 'index 2', 'index 3'], }, { name: 'var_2', input: '/path/bin:/path/to/bin:/usr/share/bin', output: ['/path/bin', '/path/to/bin', '/usr/share/bin'], }, { name: 'var_3', input: 'a b c d e f', output: ['a', 'b', 'c', 'd', 'e', 'f'], }, { name: 'var_4', input: "'a b c' d 'e f'", output: ['a b c', 'd', 'e f'], }, { name: 'var_5', input: 'a', output: ['a'], }, ]; examples.forEach(({ name, input, output }) => { it(`parse ${name}`, () => { const parsed = env.parser().parse(input); expect(env.has(name)).toBeFalsy(); // console.log(parsed); expect(parsed).toEqual(output); }); }); }); describe('append/prepend', () => { it('append existing', () => { const key = 'PATH'; const value = '/path/bin:/path/to/bin:/usr/share/bin'; env.set(key, value); env.append(key, '/usr/bin'); expect(env.getAsArray(key)).toEqual(['/path/bin', '/path/to/bin', '/usr/share/bin', '/usr/bin']); }); it('prepend existing', () => { const key = 'PATH'; const value = '/path/bin:/path/to/bin:/usr/share/bin'; env.set(key, value); env.prepend(key, '/usr/bin:/bin'); expect(env.getAsArray(key)).toEqual(['/usr/bin', '/bin', '/path/bin', '/path/to/bin', '/usr/share/bin']); }); it('append empty', () => { const key = 'prevdir'; const value = ''; env.set(key, value); env.append(key, '/usr/bin'); expect(env.getAsArray(key)).toEqual(['/usr/bin']); expect(env.get(key)).toEqual('/usr/bin'); }); it('prepend empty', () => { const key = 'dirprev'; const value = ''; env.set(key, value); env.prepend(key, '/usr/bin /bin'); expect(env.getAsArray(key)).toEqual(['/usr/bin', '/bin']); expect(env.get(key)).toEqual('/usr/bin /bin'); }); }); }); describe('workspace names', () => { let parser: Parser; let analyzer: Analyzer; beforeEach(async () => { parser = await initializeParser(); analyzer = new Analyzer(parser); }); it.only('getWorkspaceName', () => { env.set('fish_lsp_all_indexed_paths', '/usr/share/fish /usr/local/share/fish $HOME/.config/fish'); const input = 'set -gx fish_lsp_all_indexed_paths $HOME/.config/fish /usr/share/fish $__fish_data_dir'; const doc = createFakeLspDocument('config.fish', input); analyzer.analyze(doc); for (const symbol of analyzer.getFlatDocumentSymbols(doc.uri)) { if (symbol.isConfigDefinition()) { const values = symbol.valuesAsShellValues(); console.log(`values for ${symbol.name}:`, values); for (const value of values) { // const autoloaded = env.getAutoloadedKeys().find(k => k === value || env.getAsArray(k).includes(value) || (value.startsWith('$') && value.slice(1) === k)); const autoloaded = env.findAutolaodedKey(value); if (autoloaded) { console.log('found autoloaded', { autoloaded, value }); } } } } }); it.only('getWorkspaceName with autoloaded', () => { const wsURI = pathToUri(`${os.homedir()}/.config/fish`); const workspaceName = FishUriWorkspace.getWorkspaceName(wsURI); console.log('workspaceName', workspaceName); console.log(FishUriWorkspace.create(wsURI)); }); it.only('initializeEnvWorkspaces', () => { const workspaces = FishUriWorkspace.initializeEnvWorkspaces(); workspaces.forEach((ws, idx) => { console.log(idx, { ws }); }); }); }); }); // Usage: // const env = EnvManager.getInstance(); // env.set('MY_VAR', 'value'); // const value = env.get('MY_VAR'); // const childEnv = env.getForChildProcess(); // For child_process usage ================================================ FILE: tests/read-workspace.test.ts ================================================ import { TestWorkspace } from './test-workspace-utils'; describe('TestWorkspace', () => { describe('read workspace 1 from directory `workspace_1/fish`', () => { const ws = TestWorkspace.read('workspace_1/fish').initialize(); it('should read files from the specified directory', () => { const docs = ws.documents; expect(docs.length).toBeGreaterThan(2); expect(docs.map(f => f.getRelativeFilenameToWorkspace())).toContain('config.fish'); }); }); describe('read workspace 2 from directory `workspace_1`', () => { const ws = TestWorkspace.read('workspace_1').initialize(); it('should read files from the specified directory', () => { const docs = ws.documents; expect(docs.length).toBeGreaterThan(2); expect(docs.map(f => f.getRelativeFilenameToWorkspace())).toContain('config.fish'); }); }); describe('read workspace 3 from directory `workspace_1` w/config', () => { const ws = TestWorkspace.read({ folderPath: 'workspace_1' }).initialize(); it('should read files from the specified directory', () => { const docs = ws.documents; expect(docs.length).toBeGreaterThan(2); expect(docs.map(f => f.getRelativeFilenameToWorkspace())).toContain('config.fish'); }); }); }); ================================================ FILE: tests/reference-locations.test.ts ================================================ import * as fs from 'fs'; import { AnalyzedDocument, analyzer, Analyzer, EnsuredAnalyzeDocument } from '../src/analyze'; import { workspaceManager } from '../src/utils/workspace-manager'; import { fail, printClientTree, printLocations, setLogger, TestLspDocument } from './helpers'; import { getChildNodes, getRange, pointToPosition } from '../src/utils/tree-sitter'; import { isCompletionCommandDefinition } from '../src/parsing/complete'; import { isArgumentThatCanContainCommandCalls, isCommand, isCommandWithName, isDefinitionName, isEndStdinCharacter, isOption, isString, isVariable, isVariableDefinitionName } from '../src/utils/node-types'; import { getArgparseDefinitionName, isCompletionArgparseFlagWithCommandName } from '../src/parsing/argparse'; import { getRenames } from '../src/renames'; import { allUnusedLocalReferences, getReferences, getImplementation } from '../src/references'; import { Position, Location } from 'vscode-languageserver'; import { SyntaxNode } from 'web-tree-sitter'; import { documents, LspDocument } from '../src/document'; import * as path from 'path'; import { Workspace } from '../src/utils/workspace'; import { pathToUri } from '../src/utils/translation'; import { filterFirstPerScopeSymbol } from '../src/parsing/symbol'; import { isMatchingOptionValue } from '../src/parsing/options'; import { Option } from '../src/parsing/options'; import { extractCommands, extractMatchingCommandLocations } from '../src/parsing/nested-strings'; import { testChangeDocument, testClearDocuments, testOpenDocument } from './document-test-helpers'; // let currentWorkspace: CurrentWorkspace = new CurrentWorkspace(); // let documents: LspDocument[] = []; /** * @param workspacePath `path.join('__dirname', 'workspaces', 'test_workspace_NAME')` * @param docs array of `TestLspDocument` objects to create in the workspace */ const setupWorkspace = (workspacePath: string, ...docs: TestLspDocument[]) => { if (!workspacePath.includes('/')) { workspacePath = path.join(__dirname, 'workspaces', workspacePath); } const ws = Workspace.syncCreateFromUri(pathToUri(workspacePath))!; function setup() { return { rootPath: workspacePath, rootUri: pathToUri(workspacePath), beforeAll: async () => { testClearDocuments(); await Analyzer.initialize(); fs.promises.mkdir(workspacePath, { recursive: true }); const folders = ['functions', 'completions', 'conf.d']; for (const folder of folders) { const folderPath = path.join(workspacePath, folder); await fs.promises.mkdir(folderPath, { recursive: true }); } for (const doc of docs) { const fullPath = path.join(workspacePath, doc.path); await fs.promises.writeFile(fullPath, Array.isArray(doc.text) ? doc.text.join('\n') : doc.text); testOpenDocument(LspDocument.createFromPath(fullPath)); } }, beforeEach: async () => { await Analyzer.initialize(); workspaceManager.clear(); workspaceManager.setCurrent(ws); documents.all().forEach(doc => { workspaceManager.handleOpenDocument(doc); analyzer.analyze(doc); workspaceManager.current?.addUri(doc.uri); }); await workspaceManager.analyzePendingDocuments(); }, afterAll: async () => { await fs.promises.rm(workspacePath, { recursive: true }); }, documents: () => { return documents; }, }; } const setupObject = setup(); return { ...setupObject, setup: ( beforeEachCallback: () => Promise = async () => { return; }, beforeAllCallback: () => Promise = async () => { return; }, afterAllCallback: () => Promise = async () => { return; }, ) => { beforeAll(async () => { await setupObject.beforeAll(); await beforeAllCallback(); }); beforeEach(async () => { await setupObject.beforeEach(); setupObject.documents().all().forEach(doc => { testOpenDocument(doc); }); await beforeEachCallback(); }); afterAll(async () => { await setupObject.afterAll(); await afterAllCallback(); }); }, }; }; describe('find definition locations of symbols', () => { setLogger(); // logger.setSilent(true); beforeEach(async () => { await Analyzer.initialize(); }); afterEach(() => { // parser.delete(); workspaceManager.clear(); }); describe('argparse', () => { let functionDoc: LspDocument; let completionDoc: LspDocument; let confdDoc: LspDocument; setupWorkspace('test_argparse_workspace', { path: 'functions/test.fish', text: [ 'function test', ' argparse --stop-nonopt h/help name= q/quiet v/version y/yes n/no -- $argv', ' or return', ' if set -lq _flag_help', ' echo "help_msg"', ' end', ' if set -lq _flag_name && test -n "$_flag_name"', ' echo "$_flag_name"', ' end', ' if set -lq _flag_quiet', ' echo "quiet"', ' end', ' if set -lq _flag_version', ' echo "1.0.0"', ' end', ' if set -lq _flag_yes', ' echo "yes"', ' end', ' if set -lq _flag_no', ' echo "no"', ' end', ' echo $argv', 'end', ], }, { path: 'completions/test.fish', text: [ 'complete -c test -s h -l help', 'complete -c test -l name', 'complete -c test -s q -l quiet', 'complete -c test -s v -l version', 'complete -c test -s y -l yes', 'complete -c test -s n -l no', ], }, { path: 'conf.d/test.fish', text: [ 'function __test', ' test --yes', 'end', ], }, ).setup(async () => { functionDoc = documents.all().find(doc => doc.uri.endsWith('functions/test.fish'))!; completionDoc = documents.all().find(doc => doc.uri.endsWith('completions/test.fish'))!; confdDoc = documents.all().find(doc => doc.uri.endsWith('conf.d/test.fish'))!; }); it('`{functions,completions,conf.d}/test.fish`', () => { expect(documents.all()).toHaveLength(3); expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); expect(confdDoc).toBeDefined(); const nodeAtPoint = analyzer.nodeAtPoint(confdDoc.uri, 1, 10); if (nodeAtPoint && isOption(nodeAtPoint)) { const result = getReferences(confdDoc, getRange(nodeAtPoint).start); expect(result).toHaveLength(4); } }); it('test _flag_help', () => { const found = analyzer.findNode((n, document) => { return document!.uri === functionDoc.uri && n.text === '_flag_help'; })!; expect(found).toBeDefined(); const result = getReferences(functionDoc, getRange(found).start); expect(result).toHaveLength(3); }); it('test _flag_version', () => { const nodeAtPoint = analyzer.nodeAtPoint(functionDoc.uri, 1, 52)!; expect(nodeAtPoint!.text).toBe('v/version'); const refs = getReferences(functionDoc, Position.create(1, 52)); expect(refs).toHaveLength(3); }); it('complete -c test -s h -l help', () => { const nodeAtPoint = analyzer.nodeAtPoint(completionDoc.uri, 0, 27)!; expect(nodeAtPoint).toBeDefined(); expect(nodeAtPoint!.text).toBe('help'); if (nodeAtPoint.parent && isCompletionCommandDefinition(nodeAtPoint.parent)) { const def = analyzer.findSymbol((s, document) => { return functionDoc.uri === document!.uri && s.name === getArgparseDefinitionName(nodeAtPoint); })!; expect(def).toBeDefined(); } const refs = getReferences(completionDoc, Position.create(0, 27)); expect(refs).toHaveLength(3); }); }); describe('set', () => { let functionDoc: LspDocument; let confdDoc: LspDocument; let globalTestDoc: LspDocument; setupWorkspace('references_test_set_workspace', { path: 'conf.d/_foo.fish', text: [ 'function test', ' set -lx foo bar', ' echo $foo', 'end', 'test', ], }, { path: 'functions/test.fish', text: [ 'function test', ' set -lx foo bar', ' set -ql foo', ' if test -n "$foo"', ' set foo bar2', ' echo $foo', ' end', 'end', ], }, { path: 'conf.d/test.fish', text: [ 'function __test', ' set -x foo bar', 'end', 'function next', ' set foo bar', 'end', ], }, { path: 'conf.d/global_test.fish', text: [ 'set -gx foo bar', 'echo $foo', ], }, { path: 'functions/test-other.fish', text: [ 'function test-other', ' echo $foo', 'end', ], }, ).setup(async () => { functionDoc = documents.all().find(doc => doc.uri.endsWith('functions/test.fish'))!; confdDoc = documents.all().find(doc => doc.uri.endsWith('conf.d/_foo.fish'))!; globalTestDoc = documents.all().find(doc => doc.uri.endsWith('conf.d/global_test.fish'))!; }); it('foo local in conf.d/_foo.fish `2 refs for \'foo\'`', () => { expect(documents.all()).toHaveLength(5); expect(functionDoc).toBeDefined(); const found = analyzer.findNode((n, document) => { return document!.uri === confdDoc.uri && n.text === 'foo'; })!; expect(found).toBeDefined(); const result = getReferences(confdDoc, getRange(found).start); printLocations(result, { showLineText: true, }); expect(result).toHaveLength(2); }); it('foo local in functions/test.fish `5 refs for \'foo\'`', () => { const node = analyzer.getNodes(functionDoc.uri).find((n) => n.text === 'foo' && isVariableDefinitionName(n))!; expect(node).toBeDefined(); const result = getReferences(functionDoc, getRange(node).start); printLocations(result, { showText: true, showLineText: true, showIndex: true, rangeVerbose: true, }); for (const loc of result) { console.log({ uri: LspDocument.testUri(loc.uri), text: analyzer.getTextAtLocation(loc), node: analyzer.nodeAtPoint(loc.uri, loc.range.start.line, loc.range.start.character)?.text, symbol: analyzer.getFlatDocumentSymbols(loc.uri).find(s => s.equalsLocation(loc))?.toString(), }); } expect(result).toHaveLength(5); }); it('foo global', () => { const node = analyzer.getNodes(globalTestDoc.uri).find((n) => n.text === 'foo' && isVariableDefinitionName(n))!; expect(node).toBeDefined(); const result = getReferences(globalTestDoc, getRange(node).start); printLocations(result, { showText: true, showLineText: true, }); expect(result).toHaveLength(3); expect(result.map(loc => loc.uri).some(uri => uri.includes('functions/test-other.fish'))).toBeTruthy(); expect(result.map(loc => loc.uri).some(uri => uri.includes('conf.d/global_test.fish'))).toBeTruthy(); }); }); describe('alias', () => { setupWorkspace('references_test_alias_workspace', { path: 'conf.d/alias.fish', text: [ 'alias ls=\'exa\'', ], }, { path: 'functions/test.fish', text: [ 'function test', ' set -lx foo bar', ' function ls', ' builtin ls', ' end', ' ls', 'end', ], }, { path: 'functions/test-other.fish', text: [ 'function test-other', ' ls $argv', 'end', ], }, { path: 'completions/ls-wrapper.fish', text: [ 'complete -c ls-wrapper -w \'ls\'', ], }, { path: 'completions/ls.fish', text: [ 'complete -c ls -n \'command -aq ls\'', ], }, { path: 'functions/ls-wrapper.fish', text: [ 'function ls-wrapper -w=ls --wraps \'command ls\'', ' argparse -n=ls h/help -- $argv; or return 1', ' echo "ls-wrapper"', ' ls $argv', 'end', ], }, { path: 'functions/user_keybinds.fish', text: [ 'function user_keybinds', ' bind ctro-o,ctrl-l \'ls\'', 'end', ], }, { path: 'conf.d/abbrevaitons.fish', text: [ 'abbr -a ll ls -l', 'abbr -a lt -- ls -t', 'abbr -a --command=ls lt -- -lt', ], }, { path: 'functions/local-alias.fish', text: [ 'function local-alias', ' alias ls=\'ls-wrapper\'', ' ls $argv', 'end', ], }, ).setup(); it('check seen -w/--wraps nodes', () => { const values = analyzer.findNodes((n, _) => { return isMatchingOptionValue(n, Option.create('-w', '--wraps').withValue()); }).flatMap(({ nodes }) => nodes); expect(values).toHaveLength(3); }); it('check all strings that should be a function call location', () => { const symbol = analyzer.findSymbol((s, d) => { return !!(s.name === 'ls' && d?.uri.endsWith('conf.d/alias.fish')); })!; const commandCalls = analyzer.findNodes((n, d) => { if (symbol.equalsNode(n, { strict: true })) { console.log({ symbol: symbol.toString(), node: n.text, uri: d?.uri, range: getRange(n), }); } const flatSymbols = analyzer.getFlatDocumentSymbols(d.uri).filter(s => s.isLocal() && s.name === symbol.name && s.kind === symbol.kind, ); if (flatSymbols && flatSymbols.some(s => s.scopeContainsNode(n))) { return false; } if ( n.parent && isCommandWithName(n.parent, symbol.name) && n.parent.firstNamedChild?.equals(n) ) { return true; } if (isArgumentThatCanContainCommandCalls(n)) { if (isString(n) || n.text.includes('=')) { return extractCommands(n).some(cmd => cmd === symbol.name); } return n.text === symbol.name; } if (isDefinitionName(n)) return false; if (n.parent && isCommandWithName(n.parent, 'functions', 'emit', 'trap', 'command', 'bind', 'abbr')) { if (n.parent.firstNamedChild?.equals(n)) return false; if (isOption(n)) return false; if (isString(n)) return extractCommands(n).some(cmd => cmd === symbol.name); const firstIndex = isCommandWithName(n.parent, 'bind', 'abbr') ? 2 : 1; const endStdinIndex = isCommandWithName(n.parent, 'abbr') ? -1 : n.parent.children.findIndex(c => isEndStdinCharacter(c)); const children = n.parent.children.slice(firstIndex, endStdinIndex).filter(c => !isOption(c) && !isEndStdinCharacter(c)); const found = children.find(n => n.text === symbol.name); if (found) { return found.equals(n); } } return false; }); commandCalls.forEach(({ uri, nodes }, index) => { console.log(`commandCall ${index}`, { uri: LspDocument.testUri(uri), nodes: nodes.map(n => ({ text: n.text, type: n.type, startPosition: `{ row: ${n.startPosition.row}, column: ${n.startPosition.column} }`, endPosition: `{ row: ${n.endPosition.row}, column: ${n.endPosition.column} }`, })), }); }); }); it('global alias', () => { const searchDoc = documents.all().find(doc => doc.uri.endsWith('conf.d/alias.fish'))!; expect(searchDoc).toBeDefined(); const found = analyzer.findNode((n, document) => { return document!.uri === searchDoc.uri && n.text === 'ls='; })!; expect(found).toBeDefined(); const symbol = analyzer.findSymbol((s, _) => { if (s.fishKind === 'ALIAS') { return s.name === 'ls' && s.uri === searchDoc.uri; } return false; })!; const refNodes = analyzer.findNodes((n, d) => { // return isCommandWithName(n, searchSymbol.name); // return isArgumentThatCanContainCommandCalls(n) // if (isCommandName(n)) { if (symbol.equalsNode(n, { strict: true })) { console.log({ symbol: symbol.toString(), node: n.text, uri: d?.uri, range: getRange(n), }); } const flatSymbols = analyzer.getFlatDocumentSymbols(d.uri).filter(s => s.isLocal() && s.name === symbol.name && s.kind === symbol.kind, ); if (flatSymbols && flatSymbols.some(s => s.scopeContainsNode(n))) { return false; } if ( n.parent && isCommandWithName(n.parent, symbol.name) && n.parent.firstNamedChild?.equals(n) ) { return true; } if (isArgumentThatCanContainCommandCalls(n)) { if (isString(n) || n.text.includes('=')) { return extractCommands(n).some(cmd => cmd === symbol.name); } return n.text === symbol.name; } if (isDefinitionName(n)) return false; if (n.parent && isCommandWithName(n.parent, 'functions', 'emit', 'trap', 'command')) { if (n.parent.firstNamedChild?.equals(n)) return false; if (isOption(n)) return false; if (isString(n)) return extractCommands(n).some(cmd => cmd === symbol.name); return n.parent.children.slice(1).find(n => !isOption(n))?.text === symbol.name; } return false; }); let i = 0; const results: Location[] = []; for (const { uri, nodes } of refNodes) { console.log(`refNode ${i++}`, { uri, nodes: nodes.map(n => ({ text: n.text, type: n.type, startPosition: `{ row: ${n.startPosition.row}, column: ${n.startPosition.column} }`, endPosition: `{ row: ${n.endPosition.row}, column: ${n.endPosition.column} }`, })), }); nodes.forEach(n => { if (n.text !== symbol.name) { const newLocations = extractMatchingCommandLocations(symbol, n, uri); results.push(...newLocations); } else { results.push(Location.create(uri, getRange(n))); } }); } // // console.log({ // // results: results.map(loc => ({ // // }) // // }) // // // const result = getReferences(searchDoc, getRange(found).start); // printLocations(results, { // verbose: true, // }); const builtinRefs = getReferences(searchDoc, getRange(found).start); console.log('builtinRefs', builtinRefs.length); printLocations(builtinRefs, { showText: true, showLineText: true, showIndex: true, }); expect(builtinRefs).toHaveLength(12); // expect(result).toHaveLength(2); // const result = getReferencesOld(searchDoc, getRange(found).start); // expect(result).toHaveLength(2); }); it('local alias', () => { const searchDoc = documents.all().find(doc => doc.uri.endsWith('functions/local-alias.fish'))!; expect(searchDoc).toBeDefined(); const found = analyzer.findNode((n, document) => { return document!.uri === searchDoc.uri && n.text === 'ls='; })!; expect(found).toBeDefined(); const result = getReferences(searchDoc, getRange(found).start); expect(result).toHaveLength(2); }); }); describe('functions', () => { setupWorkspace( 'test_references_functions_workspace', { path: 'conf.d/foo.fish', text: [ 'function foo', ' echo \'hello there!\'', 'end', ], }, { path: 'functions/test.fish', text: [ 'function test', ' foo --help', 'end', ], }, { path: 'functions/test-other.fish', text: [ 'function test-other', ' function foo', ' echo \'general kenobi!\'', ' end', ' foo', 'end', ], }, { path: 'completions/foo.fish', text: [ 'complete -c foo -n \'test\' -s h -l help', ], }, ).setup(); it('conf.d/foo.fish -> foo function definition', () => { expect(documents.all()).toHaveLength(4); const searchDoc = documents.all().find(doc => doc.uri.endsWith('conf.d/foo.fish'))!; expect(searchDoc).toBeDefined(); const found = analyzer.findNode((n, document) => { return document!.uri === searchDoc.uri && n.text === 'foo'; })!; expect(found).toBeDefined(); const result = getReferences(searchDoc, getRange(found).start); expect(result).toHaveLength(3); const uris = new Set(result.map(loc => LspDocument.createFromUri(loc.uri).getRelativeFilenameToWorkspace())); console.log(uris); expect(uris.has('functions/test.fish')).toBeTruthy(); expect(uris.has('functions/test-other.fish')).toBeFalsy(); expect(uris.has('completions/foo.fish')).toBeTruthy(); expect(uris.has('conf.d/foo.fish')).toBeTruthy(); }); }); describe('renames', () => { describe('using `conf.d/test.fish` document', () => { let cached: EnsuredAnalyzeDocument; let document: LspDocument; setupWorkspace( 'test_renames_conf_d_workspace', { path: 'conf.d/test.fish', text: ['function test_1', ' argparse --stop-nonopt h/help name= q/quiet v/version y/yes n/no -- $argv', ' or return', ' if set -lq _flag_help', ' echo "help_msg"', ' end', ' if set -lq _flag_name && test -n "$_flag_name"', ' echo "$_flag_name"', ' end', 'end', 'function test_2', ' test_1 --help', 'end', 'complete -c test_1 -s h -l help', 'complete -c test_1 -l name', 'complete -c test_1 -s q -l quiet', 'complete -c test_1 -s v -l version', 'complete -c test_1 -s y -l yes', ], }, ).setup( async () => { document = documents.all().find(doc => doc.uri.endsWith('conf.d/test.fish'))!; cached = analyzer.analyze(document).ensureParsed(); }, ); it('child completion nodes', () => { const nodeAtPoint = analyzer.nodeAtPoint(document.uri, 1, 32); console.log(nodeAtPoint?.text); expect(nodeAtPoint).toBeDefined(); const results: SyntaxNode[] = []; getChildNodes(cached.tree.rootNode).forEach(node => { if ( isCompletionArgparseFlagWithCommandName(node, 'test_1', 'help') || isCompletionArgparseFlagWithCommandName(node, 'test_1', 'h') ) { results.push(node); } }); expect(results).toHaveLength(2); }); it('argparse references for `h/help` position inside of `help`', () => { const nodeAtPoint = analyzer.nodeAtPoint(document.uri, 1, 32); console.log(nodeAtPoint?.text); expect(nodeAtPoint).toBeDefined(); const refs = getReferences(cached.document, Position.create(1, 31)); const resultTexts: string[] = []; refs.forEach(loc => { if (analyzer.getTextAtLocation(loc).startsWith('_flag_')) { loc.range.start.character += '_flag_'.length; } resultTexts.push(analyzer.getTextAtLocation(loc)); }); expect(resultTexts).toHaveLength(4); for (const text of resultTexts) { if (text !== 'help') fail(); } }); }); describe('using \'workspaces/test_renames_workspace/{completions,functions,conf.d}/**.fish\' workspace', () => { let functionDoc: LspDocument; let completionDoc: LspDocument; let confdDoc: LspDocument; let configDoc: LspDocument; setupWorkspace('test_renames_workspace', { path: 'functions/foo_test.fish', text: [ 'function foo_test', ' argparse --stop-nonopt special-option h/help name= q/quiet v/version y/yes n/no -- $argv', ' or return', ' if set -lq _flag_help', ' echo "help_msg"', ' end', ' if set -lq _flag_name && test -n "$_flag_name"', ' echo "$_flag_name"', ' end', ' if set -lq _flag_special_option', ' echo "special-option"', ' end', 'end', ], }, { path: 'completions/foo_test.fish', text: [ 'complete -c foo_test -s h -l help', 'complete -c foo_test -l name', 'complete -c foo_test -s q -l quiet', 'complete -c foo_test -s v -l version', 'complete -c foo_test -s y -l yes', 'complete -c foo_test -s n -l no', 'complete -c foo_test -l special-option', ], }, { path: 'conf.d/__test.fish', text: [ 'function __test', ' foo_test --yes', ' foo_test --special-option', ' baz', 'end', ], }, { path: 'config.fish', text: [ 'set -gx FISH_TEST_CONFIG "test"', 'set -gx FISH_TEST_CONFIG_2 "test"', 'function foo_test_wrapper -w foo_test -d "`foo_test --yes` wrapper"', ' foo_test --yes $argv', ' foo_test --special-option="$argv"', 'end', "alias baz='foo'", ], }).setup(async () => { functionDoc = documents.all().find(doc => doc.uri.endsWith('functions/foo_test.fish'))!; completionDoc = documents.all().find(doc => doc.uri.endsWith('completions/foo_test.fish'))!; confdDoc = documents.all().find(doc => doc.uri.endsWith('conf.d/__test.fish'))!; configDoc = documents.all().find(doc => doc.uri.endsWith('config.fish'))!; expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); expect(confdDoc).toBeDefined(); expect(configDoc).toBeDefined(); }); it('setup test', () => { expect(workspaceManager.current?.uris.indexed).toHaveLength(4); expect(workspaceManager.current?.uris.all).toHaveLength(4); expect(functionDoc).toBeDefined(); expect(completionDoc).toBeDefined(); expect(confdDoc).toBeDefined(); expect(configDoc).toBeDefined(); }); it('argparse rename `name=` -> `na` test', () => { const nodeAtPoint = analyzer.nodeAtPoint(functionDoc.uri, 1, 49)!; expect(nodeAtPoint).toBeDefined(); console.debug(1, nodeAtPoint?.text); const defSymbol = analyzer.getDefinition(functionDoc, Position.create(1, 49)); const refs = getReferences(functionDoc, Position.create(1, 49)); console.log('def', { defSymbol, uri: defSymbol?.uri, rangeStart: defSymbol?.selectionRange.start, rangeEnd: defSymbol?.selectionRange.end, text: defSymbol?.name, }); printLocations(refs, { verbose: true, }); const renames = getRenames(functionDoc, Position.create(1, 49), 'na'); const newTexts: Set = new Set(); renames.forEach(loc => { newTexts.add(loc.newText); }); expect(refs).toHaveLength(5); expect(newTexts.size === 1).toBeTruthy(); }); it('argparse `special-option` test', () => { const nodeAtPoint = analyzer.nodeAtPoint(functionDoc.uri, 1, 27); expect(nodeAtPoint).toBeDefined(); expect(nodeAtPoint!.text).toBe('special-option'); console.log(2, nodeAtPoint?.text); const renames = getRenames(functionDoc, Position.create(1, 27), 'special-name'); const newTexts: Set = new Set(); const uris: Set = new Set(); renames.forEach(loc => { uris.add(loc.uri); newTexts.add(loc.newText); }); expect(renames).toHaveLength(5); expect(newTexts.size === 2).toBeTruthy(); expect(newTexts.has('special-name')).toBeTruthy(); expect(newTexts.has('special_name')).toBeTruthy(); expect(uris.size).toBe(4); }); it('function `foo_test`', () => { const nodeAtPoint = analyzer.nodeAtPoint(functionDoc.uri, 0, 11); expect(nodeAtPoint).toBeDefined(); expect(nodeAtPoint!.text).toBe('foo_test'); const refs = getRenames(functionDoc, Position.create(0, 11), 'test-rename'); const newTexts: Set = new Set(); const refUris: Set = new Set(); const countPerUri: Map = new Map(); refs.forEach(loc => { console.log('location ref', { uri: loc.uri, rangeStart: loc.range.start, rangeEnd: loc.range.end, docText: analyzer.getTextAtLocation(loc), docLine: analyzer.getDocument(loc.uri)!.getLine(loc.range.start.line), text: loc.newText, }); const count = countPerUri.get(loc.uri) || 0; countPerUri.set(loc.uri, count + 1); newTexts.add(loc.newText); refUris.add(loc.uri); }); expect(newTexts.size === 1).toBeTruthy(); // expect(refs).toHaveLength(13); expect(refUris.size).toBe(4); expect(countPerUri.get(functionDoc.uri)).toBe(1); expect(countPerUri.get(completionDoc.uri)).toBe(7); expect(countPerUri.get(confdDoc.uri)).toBe(2); expect(countPerUri.get(configDoc.uri)).toBe(3); }); it('config.fish $argv rename', () => { const argvNode = analyzer.getNodes(configDoc.uri) .find(n => n.text === '$argv' && n.parent && isCommand(n.parent))!; console.log({ argvNode: { text: argvNode.text, type: argvNode.type, startPosition: argvNode.startPosition, endPosition: argvNode.endPosition, }, parent: { type: argvNode.parent?.type, text: argvNode.parent?.text, }, uri: configDoc.uri, }); const pos = pointToPosition(argvNode!.startPosition); const renames = getRenames(configDoc, pos, 'test-argv'); expect(renames.length === 0).toBeTruthy(); }); it('alias `baz` references && renames', () => { const bazNode = analyzer.getFlatDocumentSymbols(configDoc.uri) .find(s => s.name === 'baz' && s.fishKind === 'ALIAS')!; console.log({ bazNode: { name: bazNode.name, uri: bazNode.uri, range: bazNode.range, selectionRange: bazNode.selectionRange, }, }); const bazLocation = bazNode.toLocation(); const refs = getReferences(configDoc, bazLocation.range.start); const renames = getRenames(configDoc, bazLocation.range.start, 'baz_test'); expect(refs).toHaveLength(2); expect(renames).toHaveLength(2); }); }); }); describe('references to skip', () => { let funcDoc: LspDocument; let configDoc: LspDocument; setupWorkspace('references_skip_workspace', { path: 'functions/_test.fish', text: [ 'function _test', ' set -l argv', 'end', ], }, { path: 'config.fish', text: [ 'test -d ~/.config/fish &>/dev/null', 'echo $status', 'echo $argv', 'echo $argv[1]', 'echo $pipestatus', ], }, ).setup( async () => { funcDoc = documents.all().find(doc => doc.uri.endsWith('functions/_test.fish'))!; configDoc = documents.all().find(doc => doc.uri.endsWith('config.fish'))!; }, ); it('variables to skip test', () => { const variableNodes = analyzer.getNodes(configDoc.uri).filter( n => isVariable(n) && n.type === 'variable_name', ); expect(variableNodes.length).toBe(4); variableNodes.forEach(node => { const refs = getReferences(configDoc, getRange(node).start); expect(refs).toHaveLength(0); }); }); it('function `test` -> `argv` references w/ `set -l argv`', () => { const variableNode = analyzer.getNodes(funcDoc.uri).find( n => isVariableDefinitionName(n), )!; const refs = getReferences(funcDoc, getRange(variableNode).start); expect(refs).toHaveLength(1); }); }); describe('emit event references', () => { let focusedDoc1: LspDocument; let focusedDoc2: LspDocument; let focusedDoc3: LspDocument; let customFishDoc: LspDocument; let configDoc: LspDocument; setupWorkspace('references_emit_event_workspace', { path: 'event_test.fish', text: [ 'function event_test --on-event test_event', ' echo event test: $argv', 'end', '', 'function foo', ' function bar', ' function baz', ' echo baz', ' function qux', ' echo qux', ' end', ' qux', ' end', ' baz', ' end', ' bar', 'end', 'foo', '', 'emit test_event something', ], }, { path: 'other_event_test.fish', text: [ 'function other_event_test --on-event test_event_2', ' echo other event test: $argv', 'end', '', 'emit test_event_2 something', ], }, { path: 'event_without_emit.fish', text: [ '# NOT an autoloaded file', 'function _event_without_emit --on-event test_event_a', ' echo event without emit', 'end', '', 'function other_event_without_emit --on-event test_event_b', ' echo other event without emit', 'end', 'function event_with_emit --on-event test_event_c', ' echo event with emit', 'end', 'emit test_event_c', ], }, { path: 'functions/custom_fish_prompt.fish', text: [ 'function custom_fish_prompt --on-event fish_prompt', ' echo "fish prompt $(pwd) >>>"', 'end', '', 'function __fish_configure_prompt --on-event reset_fish_prompt', ' echo resetting fish prompt', ' custom_fish_prompt', 'end', ], }, { path: 'config.fish', text: [ 'custom_fish_prompt', 'emit reset_fish_prompt', ], }, ).setup(async () => { focusedDoc1 = documents.all().find(doc => doc.uri.endsWith('event_test.fish'))!; focusedDoc2 = documents.all().find(doc => doc.uri.endsWith('other_event_test.fish'))!; focusedDoc3 = documents.all().find(doc => doc.uri.endsWith('event_without_emit.fish'))!; customFishDoc = documents.all().find(doc => doc.uri.endsWith('functions/custom_fish_prompt.fish'))!; configDoc = documents.all().find(doc => doc.uri.endsWith('config.fish'))!; expect(focusedDoc1).toBeDefined(); expect(focusedDoc2).toBeDefined(); expect(focusedDoc3).toBeDefined(); expect(customFishDoc).toBeDefined(); expect(configDoc).toBeDefined(); }); describe('all unused references', () => { it('event_test.fish', () => { const focusedDoc = focusedDoc1; const unusedRefs = allUnusedLocalReferences(focusedDoc); expect(unusedRefs).toHaveLength(0); }); it('other_event_test.fish', () => { const focusedDoc = focusedDoc2; // const allSymbols = analyzer.getDocumentSymbols(focusedDoc.uri); const symbols = filterFirstPerScopeSymbol(focusedDoc); printClientTree({ log: true }, ...symbols); const unusedRefs = allUnusedLocalReferences(focusedDoc); console.log('unused references', unusedRefs.length); printLocations(unusedRefs, { showIndex: true, showText: true, showLineText: true, }); expect(unusedRefs).toHaveLength(0); }); it('event_without_emit.fish', () => { const focusedDoc = focusedDoc3; // const allSymbols = analyzer.getDocumentSymbols(focusedDoc.uri); const symbols = filterFirstPerScopeSymbol(focusedDoc); printClientTree({ log: true }, ...symbols); const unusedRefs = allUnusedLocalReferences(focusedDoc); console.log('unused references', unusedRefs.length); printLocations(unusedRefs, { showIndex: true, showText: true, showLineText: true, }); expect(unusedRefs).toHaveLength(2); }); it('custom_fish_prompt `--on-event fish_prompt` not emitted but not show unused', () => { const focusedDoc = customFishDoc; const focusedSymbol = analyzer.getFlatDocumentSymbols(focusedDoc.uri).find(s => s.isFunction() && s.hasEventHook() && s.name === '__fish_configure_prompt')!; const allRefs = getReferences(focusedDoc, focusedSymbol.toPosition()); // console.log('ALL') // printLocations(allRefs, {verbose: true, showText: true, showLineText: true }); expect(allRefs).toHaveLength(1); const unusedRefs = allUnusedLocalReferences(focusedDoc); expect(unusedRefs).toHaveLength(0); // console.log('unused references', unusedRefs.length); // printLocations(unusedRefs, { // showIndex: true, // showText: true, // showLineText: true, // }); }); it('config.fish `reset_fish_prompt` emitted', () => { const focusedDoc = configDoc; const focusedSymbol = analyzer.getFlatDocumentSymbols(focusedDoc.uri).find(s => s.isEmittedEvent() && s.name === 'reset_fish_prompt')!; const allRefs = getReferences(focusedDoc, focusedSymbol.toPosition()); // console.log('ALL') // printLocations(allRefs, {verbose: true, showText: true, showLineText: true }); expect(allRefs).toHaveLength(2); const unusedRefs = allUnusedLocalReferences(focusedDoc); expect(unusedRefs).toHaveLength(0); }); }); describe('goto implementation', () => { it('config.fish `emit reset_fish_prompt`', () => { const focusedDoc = configDoc; const focusedSymbol = analyzer.getFlatDocumentSymbols(focusedDoc.uri).find(s => s.isEmittedEvent() && s.name === 'reset_fish_prompt')!; const impls = getImplementation(focusedDoc, focusedSymbol.toPosition()); printLocations(impls, { showIndex: true, showText: true, showLineText: true, verbose: true, }); expect(impls).toHaveLength(1); }); it('functions/custom_fish_prompt.fish -> `emit reset_fish_prompt`', () => { const focusedDoc = customFishDoc; const focusedSymbol = analyzer.getFlatDocumentSymbols(focusedDoc.uri).find(s => s.isEventHook() && s.name === 'reset_fish_prompt')!; const impls = getImplementation(focusedDoc, focusedSymbol.toPosition()); expect(impls).toHaveLength(1); }); }); }); describe('variable references edge cases', () => { setupWorkspace('test_v_ref_edge_cases_workspace', { path: 'functions/local_test_var.fish', text: [ 'set -g test_var # definition', 'set other_test_var', 'function local_test_var', ' set -l test_var local_1', ' echo $test_var # skip', ' set -l other_test_var', ' echo $other_test_var', ' echo $global_test_var', ' if test -n "$test_var" # skip', ' set -a test_var local_2', ' end', ' private_function', 'end', 'echo $test_var # outer 1', 'function private_function', ' set test_var # skip', ' set -l other_test_var', ' echo $test_var # skip', ' echo $other_test_var', ' echo $global_test_var', ' set test_var # skip', 'end', 'echo $test_var # outer 2', 'function no_skip; echo $test_var; end # used in function', 'function skip -a test_var; echo $test_var; end; # 3', 'set test_var # global inherit 4', ], }, { path: 'conf.d/global_test_var.fish', text: [ 'set -gx global_test_var', 'echo $global_test_var', 'echo $test_var # global ref 5', 'set -U universal_v -gx', 'set -gx global_fake_universal_v --universal', 'set fake_universal_v --universal', ], } , ).setup(); it('test global variable w/o local references', () => { const doc = documents.all().find(d => d.uri.endsWith('functions/local_test_var.fish'))!; expect(doc).toBeDefined(); const focusedSymbol = analyzer.getFlatDocumentSymbols(doc.uri).find(s => s.name === 'test_var')!; const refs = getReferences(doc, focusedSymbol.toPosition()); console.log({ date: new Date().toISOString(), refs: refs.length, }); printLocations(refs, { showText: true, showLineText: true, showIndex: true, }); expect(refs).toHaveLength(6); }); it('test global variable w/ local references', () => { const doc = documents.all().find(d => d.uri.endsWith('functions/local_test_var.fish'))!; expect(doc).toBeDefined(); const focusedSymbol = analyzer.getFlatDocumentSymbols(doc.uri).find(s => s.name === 'test_var' && s.parent?.name === 'local_test_var')!; console.log('focusedSymbol', focusedSymbol.toString()); const def = analyzer.getDefinition(doc, focusedSymbol.toPosition()); console.log('definition', def?.toString()); const refs = getReferences(doc, focusedSymbol.toPosition()); // console.log({ // date: new Date().toISOString(), // refs: refs.length, // }); const matchSymbols = refs.map(loc => analyzer.getSymbolAtLocation(loc)); console.log('matchSymbols', matchSymbols.map(s => s?.toString())); printLocations(refs, { showText: true, showLineText: true, showIndex: true, }); expect(refs).toHaveLength(4); }); it('test variable w/ local references && {localOnly: true}', () => { const doc = documents.all().find(d => d.uri.endsWith('functions/local_test_var.fish'))!; expect(doc).toBeDefined(); const focusedSymbol = analyzer.getFlatDocumentSymbols(doc.uri).find(s => s.name === 'test_var' && s.parent?.name === 'local_test_var')!; console.log('focusedSymbol', focusedSymbol.toString()); const def = analyzer.getDefinition(doc, focusedSymbol.toPosition()); console.log('definition', def?.toString()); const refs = getReferences(doc, focusedSymbol.toPosition(), { localOnly: true }); // console.log({ // date: new Date().toISOString(), // refs: refs.length, // }); const matchSymbols = refs.map(loc => analyzer.getSymbolAtLocation(loc)); console.log('matchSymbols', matchSymbols.map(s => s?.toString())); printLocations(refs, { showText: true, showLineText: true, showIndex: true, }); expect(refs).toHaveLength(4); }); }); }); ================================================ FILE: tests/selection-range.test.ts ================================================ import { describe, it, expect, beforeAll } from 'vitest'; import { analyzer, Analyzer } from '../src/analyze'; import { getSelectionRanges } from '../src/selection-range'; import { Position } from 'vscode-languageserver'; import { LspDocument } from '../src/document'; describe('Selection Range', () => { beforeAll(async () => { await Analyzer.initialize(); }); it('should expand selection from word to command', async () => { const content = 'echo "Hello, World!"'; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position at "echo" (line 0, char 2) const position: Position = { line: 0, character: 2 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should start with the word "echo" const firstRange = ranges[0]!.range; expect(firstRange.start.line).toBe(0); expect(firstRange.start.character).toBe(0); expect(firstRange.end.character).toBe(4); // "echo" // Should have a parent covering the entire command expect(ranges[0]!.parent).toBeDefined(); const parentRange = ranges[0]!.parent!.range; expect(parentRange.end.character).toBeGreaterThan(4); }); it('should expand selection in function definition', async () => { const content = `function greet --argument name echo "Hello, $name!" end`; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position at "greet" function name (line 0, char 10) const position: Position = { line: 0, character: 10 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // The function name "greet" const firstRange = ranges[0]!.range; expect(firstRange.start.line).toBe(0); expect(firstRange.start.character).toBe(9); expect(firstRange.end.character).toBe(14); // Should have parent covering the entire function let current = ranges[0]!.parent; let foundFunctionDefinition = false; while (current) { if (current.range.end.line === 2) { foundFunctionDefinition = true; break; } current = current.parent; } expect(foundFunctionDefinition).toBe(true); }); it('should expand selection in variable expansion', async () => { const content = 'echo "$HOME"'; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); await analyzer.analyze(doc); // Position at "HOME" variable (line 0, char 7) const position: Position = { line: 0, character: 7 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should select the variable name const firstRange = ranges[0]!.range; expect(firstRange.start.character).toBeGreaterThanOrEqual(6); expect(firstRange.end.character).toBeLessThanOrEqual(11); }); it('should expand selection in if statement', async () => { const content = `if test -n "$name" echo "Has name" end`; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position at "test" command (line 0, char 4) const position: Position = { line: 0, character: 4 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should have hierarchy: word -> command -> if_statement let current = ranges[0]; let depth = 0; while (current && depth < 10) { current = current.parent; depth++; } expect(depth).toBeGreaterThan(1); }); it('should expand selection in command substitution', async () => { const content = 'set result (greet "Alice")'; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position at "greet" inside command substitution (line 0, char 13) const position: Position = { line: 0, character: 13 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should select "greet" const firstRange = ranges[0]!.range; expect(firstRange.start.character).toBe(12); expect(firstRange.end.character).toBe(17); // Should have parent covering command inside substitution expect(ranges[0]!.parent).toBeDefined(); }); it('should handle multiple positions', async () => { const content = 'echo "Hello" && echo "World"'; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Two positions: first "echo" and second "echo" const positions: Position[] = [ { line: 0, character: 2 }, { line: 0, character: 18 }, ]; const ranges = getSelectionRanges(doc, positions); expect(ranges).toHaveLength(2); expect(ranges[0]).toBeDefined(); expect(ranges[1]).toBeDefined(); // Both should be "echo" words expect(ranges[0]!.range.end.character).toBeLessThanOrEqual(4); expect(ranges[1]!.range.start.character).toBeGreaterThanOrEqual(16); }); it('should expand selection in pipeline', async () => { const content = 'cat file.txt | grep pattern | head -n 10'; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position at "grep" (line 0, char 16) const position: Position = { line: 0, character: 16 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should select "grep" const firstRange = ranges[0]!.range; expect(firstRange.start.character).toBe(15); expect(firstRange.end.character).toBe(19); }); it('should handle nested blocks', async () => { const content = `function outer function inner echo "nested" end end`; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position at "inner" function name (line 1, char 15) const position: Position = { line: 1, character: 15 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should have multiple parents for nested structure let current = ranges[0]; let depth = 0; while (current && depth < 10) { current = current.parent; depth++; } expect(depth).toBeGreaterThan(2); }); it('should return program node for empty document', async () => { const content = ''; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); const position: Position = { line: 0, character: 0 }; const ranges = getSelectionRanges(doc, [position]); // Empty document still has a program root node expect(ranges).toHaveLength(1); expect(ranges[0]!.range.start).toEqual({ line: 0, character: 0 }); }); it('should handle position at string content', async () => { const content = 'echo "Hello, World!"'; const doc = LspDocument.createTextDocumentItem('file:///test-selection.fish', content); analyzer.analyze(doc); // Position inside the string (line 0, char 10) const position: Position = { line: 0, character: 10 }; const ranges = getSelectionRanges(doc, [position]); expect(ranges).toHaveLength(1); expect(ranges[0]).toBeDefined(); // Should eventually expand to the entire command let current = ranges[0]; while (current?.parent) { current = current.parent; } expect(current?.range.end.character).toBeGreaterThanOrEqual(20); }); }); ================================================ FILE: tests/semantic-tokens-helpers.ts ================================================ import { FishSemanticTokens, getModifiersFromMask } from '../src/utils/semantics'; import type { SemanticTokens } from 'vscode-languageserver'; /** * Decoded semantic token with human-readable fields */ export interface DecodedToken { line: number; startChar: number; length: number; tokenType: string; tokenTypeIndex: number; modifiers: string[]; modifiersMask: number; text?: string; } /** * Decode semantic tokens from LSP format to human-readable format * @param tokens - The SemanticTokens result from a provider * @param content - Optional source code content to extract text * @returns Array of decoded tokens */ export function decodeSemanticTokens( tokens: SemanticTokens, content?: string, ): DecodedToken[] { const decoded: DecodedToken[] = []; const data = tokens.data; let line = 0; let startChar = 0; for (let i = 0; i < data.length; i += 5) { const lineDelta = data[i]!; const charDelta = data[i + 1]!; const length = data[i + 2]!; const tokenTypeIndex = data[i + 3]!; const modifiersMask = data[i + 4]!; line += lineDelta; startChar = lineDelta === 0 ? startChar + charDelta : charDelta; const tokenType = FishSemanticTokens.legend.tokenTypes[tokenTypeIndex] || `UNKNOWN(${tokenTypeIndex})`; const modifiers = getModifiersFromMask(modifiersMask); const token: DecodedToken = { line, startChar, length, tokenType, tokenTypeIndex, modifiers, modifiersMask, }; if (content) { const lines = content.split('\n'); token.text = lines[line]?.substring(startChar, startChar + length) || ''; } decoded.push(token); } return decoded; } /** * Find tokens by text content */ export function findTokensByText(tokens: DecodedToken[], text: string): DecodedToken[] { return tokens.filter(t => t.text === text); } /** * Find tokens by type */ export function findTokensByType(tokens: DecodedToken[], tokenType: string): DecodedToken[] { return tokens.filter(t => t.tokenType === tokenType); } /** * Find tokens by modifier */ export function findTokensByModifier(tokens: DecodedToken[], modifier: string): DecodedToken[] { return tokens.filter(t => t.modifiers.includes(modifier)); } /** * Find tokens that have all specified modifiers */ export function findTokensWithModifiers(tokens: DecodedToken[], ...modifiers: string[]): DecodedToken[] { return tokens.filter(t => modifiers.every(mod => t.modifiers.includes(mod))); } /** * Assert that a token exists with specific properties */ export function expectTokenExists( tokens: DecodedToken[], criteria: { text?: string; tokenType?: string; modifiers?: string[]; line?: number; }, ): DecodedToken { const matches = tokens.filter(t => { if (criteria.text !== undefined && t.text !== criteria.text) return false; if (criteria.tokenType !== undefined && t.tokenType !== criteria.tokenType) return false; if (criteria.line !== undefined && t.line !== criteria.line) return false; if (criteria.modifiers !== undefined) { if (!criteria.modifiers.every(mod => t.modifiers.includes(mod))) return false; } return true; }); if (matches.length === 0) { throw new Error( `Expected to find token matching ${JSON.stringify(criteria)}, but found none.\n` + `Available tokens: ${JSON.stringify(tokens.map(t => ({ text: t.text, type: t.tokenType, mods: t.modifiers })), null, 2)}`, ); } return matches[0]!; } /** * Count tokens by type */ export function countTokensByType(tokens: DecodedToken[], tokenType: string): number { return findTokensByType(tokens, tokenType).length; } /** * Get all unique token types in the result */ export function getUniqueTokenTypes(tokens: DecodedToken[]): string[] { return [...new Set(tokens.map(t => t.tokenType))]; } /** * Get all unique modifiers in the result */ export function getUniqueModifiers(tokens: DecodedToken[]): string[] { const allModifiers = tokens.flatMap(t => t.modifiers); return [...new Set(allModifiers)]; } /** * Pretty print tokens for debugging */ export function printTokens(tokens: DecodedToken[], title?: string): void { if (title) { console.log(`\n${'='.repeat(60)}`); console.log(` ${title}`); console.log('='.repeat(60)); } tokens.forEach((token, index) => { const modsStr = token.modifiers.length > 0 ? ` [${token.modifiers.join(', ')}]` : ''; const textStr = token.text ? ` "${token.text}"` : ''; console.log( `Token ${index}: ` + `line=${token.line}, char=${token.startChar}, len=${token.length}, ` + `type=${token.tokenType}${modsStr}${textStr}`, ); }); if (title) { console.log('='.repeat(60) + '\n'); } } ================================================ FILE: tests/semantic-tokens.test.ts ================================================ import { analyzer, Analyzer } from '../src/analyze'; import { LspDocument } from '../src/document'; import { config } from '../src/config'; import { TestWorkspace, TestFile } from './test-workspace-utils'; import { Range } from 'vscode-languageserver'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { decodeSemanticTokens, findTokensByText, findTokensByType, expectTokenExists, printTokens, type DecodedToken, } from './semantic-tokens-helpers'; import { getSemanticTokensSimplest, semanticTokenHandler } from '../src/semantic-tokens'; import { getRange } from '../src/utils/tree-sitter'; import { PrebuiltDocumentationMap } from '../src/utils/snippets'; import { CompletionItemMap } from '../src/utils/completion/startup-cache'; import { FishCompletionItemKind } from '../src/utils/completion/types'; import { logger } from '../src/logger'; import { pathToUri } from '../src/utils/translation'; import { existsSync } from 'fs'; import { createFakeLspDocument, FakeLspDocument } from './helpers'; import { join } from 'path'; logger.setSilent(true); /** * Test suite for the simplified semantic token handler. * * The simplified handler is designed to provide semantic tokens for: * - FishSymbol definitions (functions and variables) * - Variable expansions ($foo, excluding the $ character) * - Command/function calls * - Keywords * - Diagnostic disable comments (@fish-lsp-disable/enable) * - Shebangs (#!/usr/bin/env fish) * - Operators (mainly end stdin operator: --) * * Unlike the full handler, this simplified version intentionally: * - Does NOT parse string interpolation * - Does NOT handle escape sequences * - Does NOT use highlights.scm queries * - Does NOT provide special bracket command handling * - Has simpler token deduplication logic */ describe('Simplified Semantic Tokens', () => { // Setup test workspace const testWorkspace = TestWorkspace.create({ name: 'semantic-tokens-simple-workspace', }).addFiles( TestFile.script('basic.fish', `#!/usr/bin/env fish # Basic fish script with common patterns function greet set -l name "World" echo "Hello, $name" end greet `), TestFile.script('variables.fish', `#!/usr/bin/env fish # Variable definitions and expansions set -l local_var "local" set -g global_var "global" set -U universal_var "universal" set -x exported_var "exported" echo $local_var echo $global_var echo $universal_var echo $exported_var echo $PATH $HOME $USER `), TestFile.script('functions.fish', `#!/usr/bin/env fish # Function definitions and calls function my_func echo "in my_func" end function another_func echo "in another_func" my_func end my_func another_func `), TestFile.script('keywords.fish', `#!/usr/bin/env fish # Keyword usage if test -f /tmp/file echo "exists" else echo "not found" end for item in a b c echo $item end while true break end switch $value case 1 echo "one" case 2 echo "two" case '*' echo "other" end `), TestFile.script('diagnostics.fish', `#!/usr/bin/env fish # Diagnostic comment handling # @fish-lsp-disable echo "disabled" # @fish-lsp-enable # @fish-lsp-disable-next-line 4004 echo "next line disabled" # Regular comment echo "normal" `), TestFile.script('operators.fish', `#!/usr/bin/env fish # Operator usage read -- my_var echo -- hello set -- args a b c `), TestFile.script('commands.fish', `#!/usr/bin/env fish # Builtin commands and user functions echo "builtin" set foo bar read -l input test -f file.txt function custom_cmd echo "custom" end custom_cmd `), TestFile.script('mixed.fish', `#!/usr/bin/env fish # Mixed features function process --argument-names input_file output_file set -l temp_var (cat $input_file) if test -n "$temp_var" echo $temp_var > $output_file end end set -g DATA_DIR /var/data process -- $DATA_DIR/input.txt $DATA_DIR/output.txt `), TestFile.completion('source_fish', ` complete -c source_fish -s f -l force -d 'Force reload of fish config' complete -c source_fish -s h -l help -d 'Show help' complete -c source_fish -s q -l quiet -d 'Silence' complete -c source_fish -l no-parse -d 'Skip parsing check' complete -c source_fish -l sleep -d 'Add sleep delay' complete -c source_fish -s e -l edit -d 'Edit ~/.config/fish/{functions,completions}/source_fish.fish files' `), TestFile.completion('deployctl', ` complete -c deployctl -s s -l stage -d 'Stage to target' complete -c deployctl -s r -l region -d 'Region to deploy' complete -c deployctl -s f -l force -d 'Skip confirmation' complete -c deployctl -l dry-run -d 'Preview actions' complete -c deployctl -l retries -d 'Retry count' `), ).initialize(); let basic_doc: LspDocument; let variables_doc: LspDocument; let functions_doc: LspDocument; let keywords_doc: LspDocument; let diagnostics_doc: LspDocument; let operators_doc: LspDocument; let commands_doc: LspDocument; let mixed_doc: LspDocument; let source_completion_doc: LspDocument; let deploy_completion_doc: LspDocument; beforeAll(async () => { logger.setSilent(true); await Analyzer.initialize(); await setupProcessEnvExecFile(); config.fish_lsp_disabled_handlers = ['diagnostic']; basic_doc = testWorkspace.getDocument('basic.fish')!; variables_doc = testWorkspace.getDocument('variables.fish')!; functions_doc = testWorkspace.getDocument('functions.fish')!; keywords_doc = testWorkspace.getDocument('keywords.fish')!; diagnostics_doc = testWorkspace.getDocument('diagnostics.fish')!; operators_doc = testWorkspace.getDocument('operators.fish')!; commands_doc = testWorkspace.getDocument('commands.fish')!; mixed_doc = testWorkspace.getDocument('mixed.fish')!; source_completion_doc = testWorkspace.getDocument('completions/source_fish.fish')!; deploy_completion_doc = testWorkspace.getDocument('completions/deployctl.fish')!; }); describe('SETUP', () => { it('should initialize all test documents', () => { expect(basic_doc).toBeDefined(); expect(variables_doc).toBeDefined(); expect(functions_doc).toBeDefined(); expect(keywords_doc).toBeDefined(); expect(diagnostics_doc).toBeDefined(); expect(operators_doc).toBeDefined(); expect(commands_doc).toBeDefined(); expect(mixed_doc).toBeDefined(); }); it('should have analyzer initialized', () => { expect(analyzer).toBeDefined(); expect(analyzer.parser).toBeDefined(); }); }); describe('Shebang Tokens', () => { it('should highlight shebangs as decorators', () => { const analyzed = analyzer.cache.getDocument(basic_doc.uri)?.ensureParsed(); expect(analyzed).toBeDefined(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, basic_doc.getText()); const shebangToken = expectTokenExists(tokens, { text: '#!/usr/bin/env fish', tokenType: 'decorator', }); expect(shebangToken).toBeDefined(); expect(shebangToken.line).toBe(0); }); it('should handle documents without shebangs', () => { const content = 'echo "no shebang"'; const doc = new FakeLspDocument({ uri: 'test://no-shebang.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const shebangTokens = tokens.filter(t => t.tokenType === 'decorator'); expect(shebangTokens.length).toBe(0); }); }); describe('Diagnostic Comment Tokens', () => { it('should highlight @fish-lsp-disable as keyword', () => { const analyzed = analyzer.cache.getDocument(diagnostics_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, diagnostics_doc.getText()); const disableTokens = tokens.filter(t => t.text?.includes('@fish-lsp-disable') && t.tokenType === 'keyword', ); expect(disableTokens.length).toBeGreaterThan(0); }); it('should highlight @fish-lsp-enable as keyword', () => { const analyzed = analyzer.cache.getDocument(diagnostics_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, diagnostics_doc.getText()); const enableTokens = tokens.filter(t => t.text?.includes('@fish-lsp-enable') && t.tokenType === 'keyword', ); expect(enableTokens.length).toBeGreaterThan(0); }); it('should highlight @fish-lsp-disable-next-line as keyword', () => { const analyzed = analyzer.cache.getDocument(diagnostics_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, diagnostics_doc.getText()); const nextLineTokens = tokens.filter(t => t.text?.includes('@fish-lsp-disable-next-line') && t.tokenType === 'keyword', ); expect(nextLineTokens.length).toBeGreaterThan(0); }); it('should NOT highlight regular comments as keywords', () => { const analyzed = analyzer.cache.getDocument(diagnostics_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, diagnostics_doc.getText()); // Regular comments should not appear as keyword tokens const regularCommentTokens = tokens.filter(t => t.text === '# Regular comment' && t.tokenType === 'keyword', ); expect(regularCommentTokens.length).toBe(0); }); }); describe('Keyword Tokens', () => { it('should highlight if/else/end keywords', () => { const analyzed = analyzer.cache.getDocument(keywords_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, keywords_doc.getText()); expectTokenExists(tokens, { text: 'if', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'else', tokenType: 'keyword' }); const endTokens = findTokensByText(tokens, 'end'); expect(endTokens.length).toBeGreaterThan(0); expect(endTokens.every(t => t.tokenType === 'keyword')).toBe(true); }); it('should highlight for/in keywords', () => { const analyzed = analyzer.cache.getDocument(keywords_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, keywords_doc.getText()); expectTokenExists(tokens, { text: 'for', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'in', tokenType: 'keyword' }); }); it('should highlight while/break keywords', () => { const analyzed = analyzer.cache.getDocument(keywords_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, keywords_doc.getText()); expectTokenExists(tokens, { text: 'while', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'break', tokenType: 'keyword' }); }); it('should highlight switch/case keywords', () => { const analyzed = analyzer.cache.getDocument(keywords_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, keywords_doc.getText()); expectTokenExists(tokens, { text: 'switch', tokenType: 'keyword' }); const caseTokens = findTokensByText(tokens, 'case'); expect(caseTokens.length).toBeGreaterThan(0); expect(caseTokens.every(t => t.tokenType === 'keyword')).toBe(true); }); it('should highlight else if keyword combination', () => { const content = 'if true; echo \'stuff...\'; else if true || false; echo \'in else if\'; else; echo \'in else...\'; end'; const doc = new FakeLspDocument({ uri: 'test://else-if.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'if' keywords (both initial 'if' and 'else if') const ifTokens = findTokensByText(tokens, 'if'); expect(ifTokens.length).toBeGreaterThanOrEqual(2); expect(ifTokens.every(t => t.tokenType === 'keyword')).toBe(true); // Should have 'else' keywords const elseTokens = findTokensByText(tokens, 'else'); expect(elseTokens.length).toBeGreaterThanOrEqual(2); expect(elseTokens.every(t => t.tokenType === 'keyword')).toBe(true); // Should have 'end' keyword expectTokenExists(tokens, { text: 'end', tokenType: 'keyword' }); // Should have 'true' and 'false' as functions (builtins are highlighted as functions with defaultLibrary modifier) const trueTokens = findTokensByText(tokens, 'true'); const falseTokens = findTokensByText(tokens, 'false'); expect(trueTokens.length).toBeGreaterThan(0); expect(falseTokens.length).toBeGreaterThan(0); expect(trueTokens.every(t => t.tokenType === 'function')).toBe(true); expect(falseTokens.every(t => t.tokenType === 'function')).toBe(true); expect(trueTokens.every(t => t.modifiers.includes('defaultLibrary'))).toBe(true); expect(falseTokens.every(t => t.modifiers.includes('defaultLibrary'))).toBe(true); // Should have 'or' keyword (||) const orTokens = findTokensByText(tokens, 'or'); if (orTokens.length > 0) { expect(orTokens.every(t => t.tokenType === 'keyword')).toBe(true); } // Should have 'echo' as function (builtins are highlighted as functions with defaultLibrary modifier) const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.every(t => t.tokenType === 'function')).toBe(true); expect(echoTokens.every(t => t.modifiers.includes('defaultLibrary'))).toBe(true); }); it('should highlight alias as keyword', () => { const content = 'alias ll="ls -la"'; const doc = new FakeLspDocument({ uri: 'test://alias.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // The simplified handler may tokenize alias definitions as functions // Just verify we get some semantic tokens for the alias statement expect(tokens.length).toBeGreaterThan(0); }); it('should highlight logical operators and/or/not as keywords', () => { const content = 'command1 && command2 || command3'; const doc = new FakeLspDocument({ uri: 'test://logical-ops.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'and' and 'or' as keywords (representing && and ||) const andTokens = findTokensByText(tokens, 'and'); const orTokens = findTokensByText(tokens, 'or'); if (andTokens.length > 0) { expect(andTokens.every(t => t.tokenType === 'keyword')).toBe(true); } if (orTokens.length > 0) { expect(orTokens.every(t => t.tokenType === 'keyword')).toBe(true); } }); it('should highlight not operator as keyword', () => { const content = 'not test -f /tmp/file.txt'; const doc = new FakeLspDocument({ uri: 'test://not-op.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'not' as keyword const notTokens = findTokensByText(tokens, 'not'); expect(notTokens.length).toBeGreaterThan(0); expect(notTokens.every(t => t.tokenType === 'keyword')).toBe(true); // Should also have 'test' as function (builtins are highlighted as functions with defaultLibrary modifier) const testTokens = findTokensByText(tokens, 'test'); expect(testTokens.length).toBeGreaterThan(0); expect(testTokens.some(t => t.tokenType === 'function')).toBe(true); expect(testTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); }); }); describe('Alias Definitions', () => { it('should highlight alias keyword and function name in "alias foo=bar"', () => { const content = 'alias foo=bar'; const doc = new FakeLspDocument({ uri: 'test://alias-def.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'alias' as keyword const aliasTokens = findTokensByText(tokens, 'alias'); expect(aliasTokens.length).toBeGreaterThan(0); expect(aliasTokens.some(t => t.tokenType === 'keyword')).toBe(true); // Should have 'foo' as function (the alias name being defined) const fooTokens = findTokensByText(tokens, 'foo'); expect(fooTokens.length).toBeGreaterThan(0); expect(fooTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should handle alias with quoted value "alias ll="ls -la""', () => { const content = 'alias ll="ls -la"'; const doc = new FakeLspDocument({ uri: 'test://alias-quoted.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'alias' as keyword const aliasTokens = findTokensByText(tokens, 'alias'); expect(aliasTokens.length).toBeGreaterThan(0); expect(aliasTokens.some(t => t.tokenType === 'keyword')).toBe(true); // Should have 'll' as function const llTokens = findTokensByText(tokens, 'll'); expect(llTokens.length).toBeGreaterThan(0); expect(llTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should handle multiple alias definitions', () => { const content = `alias gs="git status" alias gc="git commit" alias gp="git push"`; const doc = new FakeLspDocument({ uri: 'test://aliases-multiple.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 3 'alias' keyword tokens const aliasTokens = findTokensByText(tokens, 'alias'); expect(aliasTokens.length).toBeGreaterThanOrEqual(3); expect(aliasTokens.every(t => t.tokenType === 'keyword')).toBe(true); // Should have function tokens for gs, gc, gp const gsTokens = findTokensByText(tokens, 'gs'); const gcTokens = findTokensByText(tokens, 'gc'); const gpTokens = findTokensByText(tokens, 'gp'); expect(gsTokens.some(t => t.tokenType === 'function')).toBe(true); expect(gcTokens.some(t => t.tokenType === 'function')).toBe(true); expect(gpTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should handle alias with space syntax "alias ll ls -la"', () => { const content = 'alias ll ls -la'; const doc = new FakeLspDocument({ uri: 'test://alias-space.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'alias' as keyword const aliasTokens = findTokensByText(tokens, 'alias'); expect(aliasTokens.length).toBeGreaterThan(0); expect(aliasTokens.some(t => t.tokenType === 'keyword')).toBe(true); // Should have 'll' as function const llTokens = findTokensByText(tokens, 'll'); expect(llTokens.length).toBeGreaterThan(0); expect(llTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should handle alias with complex command', () => { const content = 'alias gs="git status --short --branch"'; const doc = new FakeLspDocument({ uri: 'test://alias-complex.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'alias' as keyword const aliasTokens = findTokensByText(tokens, 'alias'); expect(aliasTokens.length).toBeGreaterThan(0); expect(aliasTokens.some(t => t.tokenType === 'keyword')).toBe(true); // Should have 'gs' as function const gsTokens = findTokensByText(tokens, 'gs'); expect(gsTokens.length).toBeGreaterThan(0); expect(gsTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should distinguish alias definition from alias usage', () => { const content = `alias myalias="echo test" myalias`; const doc = new FakeLspDocument({ uri: 'test://alias-usage.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'alias' as keyword const aliasTokens = findTokensByText(tokens, 'alias'); expect(aliasTokens.length).toBeGreaterThan(0); expect(aliasTokens.some(t => t.tokenType === 'keyword')).toBe(true); // Should have 'myalias' tokens - both as function (definition and call) const myaliasTokens = findTokensByText(tokens, 'myalias'); expect(myaliasTokens.length).toBeGreaterThan(0); // Should have at least one function token (for the definition) // The call might be highlighted as keyword or function depending on how aliases are handled expect(myaliasTokens.some(t => t.tokenType === 'function')).toBe(true); }); }); describe('Variable Tokens', () => { it('should highlight variable definitions', () => { const analyzed = analyzer.cache.getDocument(variables_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, variables_doc.getText()); // Should find variable tokens (without $ prefix in token text) const varTokens = findTokensByType(tokens, 'variable'); expect(varTokens.length).toBeGreaterThan(0); // Check specific variables const localVarTokens = findTokensByText(tokens, 'local_var'); const globalVarTokens = findTokensByText(tokens, 'global_var'); expect(localVarTokens.length).toBeGreaterThan(0); expect(globalVarTokens.length).toBeGreaterThan(0); }); it('should highlight export command and variable in "export VAR=value"', () => { const content = 'export MY_VAR=hello'; const doc = new FakeLspDocument({ uri: 'test://export-var.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'export' as command/function call const exportTokens = findTokensByText(tokens, 'export'); expect(exportTokens.length).toBeGreaterThan(0); // export is a builtin command, so it could be keyword or function expect(exportTokens.some(t => t.tokenType === 'keyword' || t.tokenType === 'function')).toBe(true); // Should have 'MY_VAR' as variable const varTokens = findTokensByText(tokens, 'MY_VAR'); expect(varTokens.length).toBeGreaterThan(0); expect(varTokens.some(t => t.tokenType === 'variable')).toBe(true); }); it('should handle multiple export statements', () => { const content = `export PATH=/usr/local/bin export EDITOR=vim export LANG=en_US.UTF-8`; const doc = new FakeLspDocument({ uri: 'test://exports-multiple.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 3 'export' tokens const exportTokens = findTokensByText(tokens, 'export'); expect(exportTokens.length).toBeGreaterThanOrEqual(3); // Should have variable tokens for PATH, EDITOR, LANG const pathTokens = findTokensByText(tokens, 'PATH'); const editorTokens = findTokensByText(tokens, 'EDITOR'); const langTokens = findTokensByText(tokens, 'LANG'); expect(pathTokens.some(t => t.tokenType === 'variable')).toBe(true); expect(editorTokens.some(t => t.tokenType === 'variable')).toBe(true); expect(langTokens.some(t => t.tokenType === 'variable')).toBe(true); }); it('should handle export with quoted values', () => { const content = 'export MY_PATH="/usr/local/bin:/usr/bin"'; const doc = new FakeLspDocument({ uri: 'test://export-quoted.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'export' token const exportTokens = findTokensByText(tokens, 'export'); expect(exportTokens.length).toBeGreaterThan(0); // Should have 'MY_PATH' as variable const varTokens = findTokensByText(tokens, 'MY_PATH'); expect(varTokens.length).toBeGreaterThan(0); expect(varTokens.some(t => t.tokenType === 'variable')).toBe(true); }); it('should handle export with variable expansion in value', () => { const content = 'export PATH=/opt/bin:$PATH'; const doc = new FakeLspDocument({ uri: 'test://export-expansion.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'export' token const exportTokens = findTokensByText(tokens, 'export'); expect(exportTokens.length).toBeGreaterThan(0); // Should have 'PATH' tokens (both as definition and expansion) const pathTokens = findTokensByText(tokens, 'PATH'); expect(pathTokens.length).toBeGreaterThan(0); // All PATH tokens should be variables expect(pathTokens.every(t => t.tokenType === 'variable')).toBe(true); }); it('should highlight variable expansions WITHOUT $ character', () => { const analyzed = analyzer.cache.getDocument(variables_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, variables_doc.getText()); // Variable tokens should NOT include the $ character const dollarTokens = tokens.filter(t => t.text?.startsWith('$')); expect(dollarTokens.length).toBe(0); // But should have tokens for PATH, HOME, USER (without $) const pathTokens = findTokensByText(tokens, 'PATH'); const homeTokens = findTokensByText(tokens, 'HOME'); const userTokens = findTokensByText(tokens, 'USER'); expect(pathTokens.length).toBeGreaterThan(0); expect(homeTokens.length).toBeGreaterThan(0); expect(userTokens.length).toBeGreaterThan(0); // All should be variable type expect(pathTokens.every(t => t.tokenType === 'variable')).toBe(true); expect(homeTokens.every(t => t.tokenType === 'variable')).toBe(true); expect(userTokens.every(t => t.tokenType === 'variable')).toBe(true); }); it('should handle nested variable expansions', () => { const content = 'echo $argv[1]'; const doc = new FakeLspDocument({ uri: 'test://nested-var.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Note: The simplified handler may tokenize $argv[1] differently // It should at least provide some semantic tokens for the variable expansion const varTokens = findTokensByType(tokens, 'variable'); expect(varTokens.length).toBeGreaterThanOrEqual(0); }); it('should highlight for loop variable as variable token', () => { const content = 'for item in $list; echo $item; end'; const doc = new FakeLspDocument({ uri: 'test://for-loop-var.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'for', 'in', 'end' as keywords expectTokenExists(tokens, { text: 'for', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'in', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'end', tokenType: 'keyword' }); // Should have 'item' as variable (loop variable + expansion) const itemTokens = findTokensByText(tokens, 'item'); expect(itemTokens.length).toBeGreaterThan(0); expect(itemTokens.some(t => t.tokenType === 'variable')).toBe(true); // Should have 'list' as variable (from $list expansion) const listTokens = findTokensByText(tokens, 'list'); expect(listTokens.length).toBeGreaterThan(0); expect(listTokens.some(t => t.tokenType === 'variable')).toBe(true); // Should have 'echo' as function (builtins are highlighted as functions with defaultLibrary modifier) expectTokenExists(tokens, { text: 'echo', tokenType: 'function', modifiers: ['defaultLibrary'] }); }); it('should handle for loop with multiple iteration variables', () => { const content = 'for x in a b c; echo $x; end'; const doc = new FakeLspDocument({ uri: 'test://for-loop-multi.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'x' as variable const xTokens = findTokensByText(tokens, 'x'); expect(xTokens.length).toBeGreaterThan(0); expect(xTokens.some(t => t.tokenType === 'variable')).toBe(true); }); }); describe('Function Tokens', () => { it('should highlight function definitions', () => { const analyzed = analyzer.cache.getDocument(functions_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, functions_doc.getText()); const myFuncTokens = findTokensByText(tokens, 'my_func'); const anotherFuncTokens = findTokensByText(tokens, 'another_func'); expect(myFuncTokens.length).toBeGreaterThan(0); expect(anotherFuncTokens.length).toBeGreaterThan(0); // Should have function tokens (may also have keyword tokens for 'function' keyword) expect(myFuncTokens.some(t => t.tokenType === 'function')).toBe(true); expect(anotherFuncTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should highlight function argument names as variables', () => { const content = 'function foo --argument-names a b c d e --description "foo test function"; echo $a $b $c $d $e; end'; const doc = new FakeLspDocument({ uri: 'test://func-args.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'function' and 'end' as keywords expectTokenExists(tokens, { text: 'function', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'end', tokenType: 'keyword' }); // Should have 'foo' as function const fooTokens = findTokensByText(tokens, 'foo'); expect(fooTokens.length).toBeGreaterThan(0); expect(fooTokens.some(t => t.tokenType === 'function')).toBe(true); // Should have a, b, c, d, e as variables (argument names + expansions) const argNames = ['a', 'b', 'c', 'd', 'e']; argNames.forEach(argName => { const argTokens = findTokensByText(tokens, argName); expect(argTokens.length).toBeGreaterThan(0); expect(argTokens.some(t => t.tokenType === 'variable')).toBe(true); }); // Should have 'echo' as function (builtins are highlighted as functions with defaultLibrary modifier) const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.tokenType === 'function')).toBe(true); expect(echoTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); }); it('should highlight function calls', () => { const analyzed = analyzer.cache.getDocument(functions_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, functions_doc.getText()); // Function calls should be highlighted const funcTokens = findTokensByType(tokens, 'function'); expect(funcTokens.length).toBeGreaterThan(0); }); // now just defaultLibrary modifier for builtins it('should differentiate between builtin commands (defaultModifier) and user functions', () => { const analyzed = analyzer.cache.getDocument(commands_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, commands_doc.getText()); // All should use function/command token type const echoTokens = findTokensByText(tokens, 'echo'); const setTokens = findTokensByText(tokens, 'set'); const customCmdTokens = findTokensByText(tokens, 'custom_cmd'); expect(echoTokens.length).toBeGreaterThan(0); expect(setTokens.length).toBeGreaterThan(0); expect(customCmdTokens.length).toBeGreaterThan(0); }); }); describe('Bracket Test Command', () => { it('should highlight [ and ] in test command "[ -f /tmp/foo.fish ]"', () => { const content = '[ -f /tmp/foo.fish ]'; const doc = new FakeLspDocument({ uri: 'test://bracket-test.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have [ and ] as command tokens const openBracketTokens = findTokensByText(tokens, '['); const closeBracketTokens = findTokensByText(tokens, ']'); expect(openBracketTokens.length).toBeGreaterThan(0); expect(closeBracketTokens.length).toBeGreaterThan(0); // Both should be command/function type expect(openBracketTokens.some(t => t.tokenType === 'function' || t.tokenType === 'command')).toBe(true); expect(closeBracketTokens.some(t => t.tokenType === 'function' || t.tokenType === 'command')).toBe(true); }); it('should highlight [ and ] in test command "[ -d /tmp ]"', () => { const content = '[ -d /tmp ]'; const doc = new FakeLspDocument({ uri: 'test://bracket-dir-test.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have [ and ] tokens const bracketTokens = tokens.filter(t => t.text === '[' || t.text === ']'); expect(bracketTokens.length).toBeGreaterThanOrEqual(2); }); it('should highlight [ and ] in test command "[ -n \'some-non-empty-string\' ]"', () => { const content = "[ -n 'some-non-empty-string' ]"; const doc = new FakeLspDocument({ uri: 'test://bracket-string-test.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have [ and ] tokens const openBracketTokens = findTokensByText(tokens, '['); const closeBracketTokens = findTokensByText(tokens, ']'); expect(openBracketTokens.length).toBeGreaterThan(0); expect(closeBracketTokens.length).toBeGreaterThan(0); }); it('should NOT confuse array indexing with test command in "echo $argv[1]"', () => { const content = 'echo $argv[1]'; const doc = new FakeLspDocument({ uri: 'test://array-index.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'echo' as function (builtins are highlighted as functions with defaultLibrary modifier) const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.tokenType === 'function')).toBe(true); expect(echoTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); // Should have variable tokens (the simplified handler handles array indexing) const varTokens = findTokensByType(tokens, 'variable'); expect(varTokens.length).toBeGreaterThanOrEqual(0); // May or may not tokenize array indexing // Should NOT have [ or ] as command tokens (they're part of array indexing) // If there are bracket tokens, they should NOT be command type const bracketTokens = tokens.filter(t => t.text === '[' || t.text === ']'); const commandBracketTokens = bracketTokens.filter(t => t.tokenType === 'command' || t.tokenType === 'function'); expect(commandBracketTokens.length).toBe(0); }); it('should handle multiple [ ] test commands', () => { const content = '[ -f /tmp/a ] && [ -d /tmp/b ]'; const doc = new FakeLspDocument({ uri: 'test://multiple-brackets.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 2 opening [ and 2 closing ] const openBracketTokens = findTokensByText(tokens, '['); const closeBracketTokens = findTokensByText(tokens, ']'); expect(openBracketTokens.length).toBeGreaterThanOrEqual(2); expect(closeBracketTokens.length).toBeGreaterThanOrEqual(2); }); it('should handle [ ] in if statement', () => { const content = 'if [ -f /tmp/file.txt ]; echo "exists"; end'; const doc = new FakeLspDocument({ uri: 'test://bracket-if.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have [ and ] tokens const bracketTokens = tokens.filter(t => t.text === '[' || t.text === ']'); expect(bracketTokens.length).toBeGreaterThanOrEqual(2); // Should also have if, echo, end keywords expectTokenExists(tokens, { text: 'if', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'echo', tokenType: 'function', modifiers: ['defaultLibrary'] }); expectTokenExists(tokens, { text: 'end', tokenType: 'keyword' }); }); }); describe('Command Substitution', () => { it('should highlight commands in command substitution (parentheses)', () => { const content = 'set output (echo test)'; const doc = new FakeLspDocument({ uri: 'test://cmd-sub-parens.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'set' as function (builtins are highlighted as functions with defaultLibrary modifier) const setTokens = findTokensByText(tokens, 'set'); expect(setTokens.length).toBeGreaterThan(0); expect(setTokens.some(t => t.tokenType === 'function')).toBe(true); expect(setTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); // Should have 'echo' as function (inside command substitution) const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.tokenType === 'function')).toBe(true); expect(echoTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); // Should have 'output' as variable const outputTokens = findTokensByText(tokens, 'output'); expect(outputTokens.length).toBeGreaterThan(0); expect(outputTokens.some(t => t.tokenType === 'variable')).toBe(true); }); it('should highlight commands in dollar command substitution', () => { const content = 'echo "$(date)"'; const doc = new FakeLspDocument({ uri: 'test://cmd-sub-dollar.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'echo' as function (builtins are highlighted as functions with defaultLibrary modifier) const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.tokenType === 'function')).toBe(true); expect(echoTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); // Should have 'date' as keyword/command (inside command substitution) const dateTokens = findTokensByText(tokens, 'date'); expect(dateTokens.length).toBeGreaterThan(0); // Could be keyword or command depending on how it's classified expect(dateTokens.some(t => t.tokenType === 'keyword' || t.tokenType === 'command' || t.tokenType === 'function')).toBe(true); }); it('should handle nested command substitution with variables', () => { const content = 'set result (count (echo $argv))'; const doc = new FakeLspDocument({ uri: 'test://cmd-sub-nested.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'set', 'count', 'echo' as functions (builtins are highlighted as functions with defaultLibrary modifier) expectTokenExists(tokens, { text: 'set', tokenType: 'function', modifiers: ['defaultLibrary'] }); const countTokens = findTokensByText(tokens, 'count'); expect(countTokens.length).toBeGreaterThan(0); expect(countTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.modifiers.includes('defaultLibrary'))).toBe(true); // Should have 'result' and 'argv' as variables const resultTokens = findTokensByText(tokens, 'result'); expect(resultTokens.length).toBeGreaterThan(0); expect(resultTokens.some(t => t.tokenType === 'variable')).toBe(true); const argvTokens = findTokensByText(tokens, 'argv'); expect(argvTokens.length).toBeGreaterThanOrEqual(0); // May or may not be tokenized }); }); describe.skip('Nested Structures', () => { it('should handle command substitution inside test command', () => { const content = 'if test (count $argv) -gt 0; echo "has args"; end'; const doc = createFakeLspDocument('test://nested-test.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'if', 'end' as keywords expectTokenExists(tokens, { text: 'if', tokenType: 'keyword' }); expectTokenExists(tokens, { text: 'end', tokenType: 'keyword' }); // Should have 'test' as keyword const testTokens = findTokensByText(tokens, 'test'); expect(testTokens.length).toBeGreaterThan(0); expect(testTokens.some(t => t.tokenType === 'function')).toBe(true); // Should have 'count' as keyword/command const countTokens = findTokensByText(tokens, 'count'); expect(countTokens.length).toBeGreaterThan(0); // Should have 'echo' as keyword const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should handle deeply nested command substitution', () => { const content = 'echo (string upper (string lower (echo "TEST")))'; const doc = createFakeLspDocument('test://deeply-nested.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'echo' tokens (appears multiple times) const echoTokens = findTokensByText(tokens, 'echo'); expect(echoTokens.length).toBeGreaterThan(0); expect(echoTokens.some(t => t.tokenType === 'function')).toBe(true); // Should have 'string' tokens const stringTokens = findTokensByText(tokens, 'string'); expect(stringTokens.length).toBeGreaterThan(0); expect(stringTokens.some(t => t.tokenType === 'function')).toBe(true); }); it('should handle variable expansion in command substitution', () => { const content = 'set files (ls $HOME)'; const doc = createFakeLspDocument('test://var-in-cmd-sub.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have 'set' as keyword expectTokenExists(tokens, { text: 'set', tokenType: 'keyword' }); // Should have 'files' as variable const filesTokens = findTokensByText(tokens, 'files'); expect(filesTokens.length).toBeGreaterThan(0); expect(filesTokens.some(t => t.tokenType === 'variable')).toBe(true); // Should have 'HOME' as variable const homeTokens = findTokensByText(tokens, 'HOME'); expect(homeTokens.length).toBeGreaterThan(0); expect(homeTokens.some(t => t.tokenType === 'variable')).toBe(true); // Should have 'ls' as keyword/command const lsTokens = findTokensByText(tokens, 'ls'); expect(lsTokens.length).toBeGreaterThan(0); }); }); describe('Operator Tokens', () => { it('should highlight -- (end stdin) as operator', () => { const analyzed = analyzer.cache.getDocument(operators_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, operators_doc.getText()); const operatorTokens = tokens.filter(t => t.text === '--' && t.tokenType === 'operator', ); expect(operatorTokens.length).toBeGreaterThan(0); }); it('should handle -- in various command contexts', () => { const content = `read -- var echo -- text set -- args a b c`; const doc = createFakeLspDocument('test://operators-multiple.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const operatorTokens = tokens.filter(t => t.text === '--' && t.tokenType === 'operator', ); // Should have at least one -- operator token expect(operatorTokens.length).toBeGreaterThan(0); }); }); describe('Mixed Features', () => { it('should handle complex documents with multiple token types', () => { const analyzed = analyzer.cache.getDocument(mixed_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, mixed_doc.getText()); // Should have multiple types of tokens const tokenTypes = new Set(tokens.map(t => t.tokenType)); // Should have keywords const keywordTokens = tokens.filter(t => t.tokenType === 'keyword'); expect(keywordTokens.length).toBeGreaterThan(0); // Should have variables const variableTokens = tokens.filter(t => t.tokenType === 'variable'); expect(variableTokens.length).toBeGreaterThan(0); // Should have functions const functionTokens = tokens.filter(t => t.tokenType === 'function'); expect(functionTokens.length).toBeGreaterThan(0); // Should have operators const operatorTokens = tokens.filter(t => t.tokenType === 'operator'); expect(operatorTokens.length).toBeGreaterThan(0); // Should have at least 4 different token types expect(tokenTypes.size).toBeGreaterThanOrEqual(4); }); it('should not create overlapping tokens', () => { const analyzed = analyzer.cache.getDocument(mixed_doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, mixed_doc.getText()); // Check for overlapping tokens on the same line const tokensByLine = new Map(); tokens.forEach(token => { if (!tokensByLine.has(token.line)) { tokensByLine.set(token.line, []); } tokensByLine.get(token.line)!.push(token); }); tokensByLine.forEach((lineTokens, line) => { // Sort tokens by start position const sorted = lineTokens.sort((a, b) => a.startChar - b.startChar); // Check for overlaps for (let i = 0; i < sorted.length - 1; i++) { const current = sorted[i]!; const next = sorted[i + 1]!; const currentEnd = current.startChar + current.length; // Next token should start at or after current token ends expect(next.startChar).toBeGreaterThanOrEqual(currentEnd); } }); }); }); describe('Range Support', () => { it('should support full document range', () => { const analyzed = analyzer.cache.getDocument(basic_doc.uri)?.ensureParsed(); const fullRange = getRange(analyzed!.root); const result = getSemanticTokensSimplest(analyzed!, fullRange); expect(result.data).toBeDefined(); expect(result.data.length).toBeGreaterThan(0); }); it('should support partial range requests', () => { const analyzed = analyzer.cache.getDocument(keywords_doc.uri)?.ensureParsed(); // Request only first 5 lines const partialRange: Range = { start: { line: 0, character: 0 }, end: { line: 5, character: 0 }, }; const result = getSemanticTokensSimplest(analyzed!, partialRange); expect(result.data).toBeDefined(); // Should have some tokens but potentially fewer than full document expect(result.data.length).toBeGreaterThanOrEqual(0); }); }); describe('Edge Cases', () => { it('should handle empty documents', () => { const content = ''; const doc = createFakeLspDocument('test://empty.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); if (!analyzed) { expect(analyzed).toBeDefined(); return; } const result = getSemanticTokensSimplest(analyzed, getRange(analyzed.root)); expect(result.data).toBeDefined(); expect(result.data.length).toBe(0); }); it('should handle documents with only comments', () => { const content = `# Just a comment # Another comment`; const doc = createFakeLspDocument('test://comments-only.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have no keyword tokens (since these are regular comments) const keywordTokens = tokens.filter(t => t.tokenType === 'keyword'); expect(keywordTokens.length).toBe(0); }); it('should handle documents with syntax errors gracefully', () => { const content = `function broken echo "missing end" set incomplete`; const doc = createFakeLspDocument( 'test://syntax-error.fish', content, ); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); // Should not throw expect(() => { const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); decodeSemanticTokens(result, content); }).not.toThrow(); }); it('should handle very long variable names', () => { const longName = 'a'.repeat(200); const content = `set -g ${longName} "value"\necho $${longName}`; const doc = createFakeLspDocument( 'test://long-var.fish', content, ); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should handle long names without crashing const varTokens = tokens.filter(t => t.tokenType === 'variable'); expect(varTokens.length).toBeGreaterThan(0); }); }); describe('Handler Integration', () => { it('should work with semanticTokenHandler for full document', () => { const params = { textDocument: { uri: basic_doc.uri }, }; const result = semanticTokenHandler(params); expect(result.data).toBeDefined(); expect(result.data.length).toBeGreaterThan(0); }); it('should work with semanticTokenHandler for range requests', () => { const params = { textDocument: { uri: basic_doc.uri }, range: { start: { line: 0, character: 0 }, end: { line: 5, character: 0 }, }, }; const result = semanticTokenHandler(params); expect(result.data).toBeDefined(); }); it('should return empty data for non-existent document', () => { const params = { textDocument: { uri: 'test://does-not-exist.fish' }, }; const result = semanticTokenHandler(params); expect(result.data).toBeDefined(); expect(result.data.length).toBe(0); }); }); describe('complete', () => { const logCompletionTokens = (label: string, doc: LspDocument) => { const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); if (!analyzed) { logger.warning(`[semantic-tokens.complete:${label}]`, 'document not analyzed'); return; } const result = getSemanticTokensSimplest(analyzed, getRange(analyzed.root)); const tokens = decodeSemanticTokens(result, doc.getText()); logger.log( `[semantic-tokens.complete:${label}]`, tokens.map(token => ({ line: token.line, startChar: token.startChar, text: token.text, type: token.tokenType, modifiers: token.modifiers, })), ); printTokens(tokens, `complete:${label}`); }; it('logs tokens for source_fish completions file', () => { logCompletionTokens('source_fish', source_completion_doc); }); it('logs tokens for deployctl completions file', () => { logCompletionTokens('deployctl', deploy_completion_doc); }); }); describe('function.defaultLibrary', () => { const functionNamesToCheck = [ 'fish_add_path', 'fish_config', 'fish_default_key_bindings', 'fish_mode_prompt', 'fish_opt', 'fish_prompt', 'fish_title', 'fish_update_completions', 'fish_vcs_prompt', '__fish_print_help', '__fish_contains_opt', 'isatty', 'open', ]; const fishFunctionsDir = '/usr/share/fish/functions'; let prebuiltCommandNames: Set = new Set(); let analyzerSymbolNames: Set = new Set(); let startupCompletionMap: CompletionItemMap | null = null; const logCoverage = (source: string, predicate: (name: string) => boolean) => { functionNamesToCheck.forEach(name => { const found = predicate(name); console.log(`[function.defaultLibrary:${source}]`, { name, found }); }); }; beforeAll(async () => { prebuiltCommandNames = new Set( PrebuiltDocumentationMap.getByType('command').map(entry => entry.name), ); // PrebuiltDocumentationMap.getByType('command').forEach(entry => console.log(entry.name)); functionNamesToCheck.forEach(name => { const fsPath = join(fishFunctionsDir, `${name}.fish`); if (!existsSync(fsPath)) { console.warn(`[function.defaultLibrary] missing file: ${fsPath}`); return; } analyzer.analyzePath(pathToUri(fsPath)); }); analyzerSymbolNames = new Set(analyzer.globalSymbols.allNames); try { startupCompletionMap = await CompletionItemMap.initialize(); } catch (error) { console.error('[function.defaultLibrary] CompletionItemMap.initialize failed', error); } }); it('logs PrebuiltDocumentationMap command coverage', () => { logCoverage('prebuilt', name => prebuiltCommandNames.has(name)); }); it('logs analyzer global symbol coverage', () => { logCoverage('analyzer', name => analyzerSymbolNames.has(name)); }); it('logs completion startup cache coverage', () => { if (!startupCompletionMap) { logger.warning('[function.defaultLibrary:startup-cache] Completion map unavailable'); return; } logCoverage('startup-cache', name => Boolean( startupCompletionMap?.findLabel( name, FishCompletionItemKind.FUNCTION, FishCompletionItemKind.BUILTIN, ), ), ); }); }); describe('Builtin Modifiers', () => { it('should highlight echo with defaultLibrary modifier', () => { const content = 'echo "hello"'; const doc = new FakeLspDocument({ uri: 'test://echo-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const echoToken = expectTokenExists(tokens, { text: 'echo', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(echoToken).toBeDefined(); }); it('should highlight set with defaultLibrary modifier', () => { const content = 'set -l foo bar'; const doc = new FakeLspDocument({ uri: 'test://set-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const setToken = expectTokenExists(tokens, { text: 'set', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(setToken).toBeDefined(); }); it('should highlight test with defaultLibrary modifier', () => { const content = 'test -f /tmp/file'; const doc = new FakeLspDocument({ uri: 'test://test-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const testToken = expectTokenExists(tokens, { text: 'test', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(testToken).toBeDefined(); }); it('should highlight true and false with defaultLibrary modifier', () => { const content = 'true && false'; const doc = new FakeLspDocument({ uri: 'test://bool-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const trueToken = expectTokenExists(tokens, { text: 'true', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(trueToken).toBeDefined(); const falseToken = expectTokenExists(tokens, { text: 'false', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(falseToken).toBeDefined(); }); it('should highlight count with defaultLibrary modifier', () => { const content = 'count $argv'; const doc = new FakeLspDocument({ uri: 'test://count-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const countToken = expectTokenExists(tokens, { text: 'count', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(countToken).toBeDefined(); }); it('should highlight string with defaultLibrary modifier', () => { const content = 'string match -r "foo" bar'; const doc = new FakeLspDocument({ uri: 'test://string-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); const stringToken = expectTokenExists(tokens, { text: 'string', tokenType: 'function', modifiers: ['defaultLibrary'], }); expect(stringToken).toBeDefined(); }); it('should NOT have defaultLibrary modifier on user-defined functions', () => { const content = `function my_func echo "test" end my_func`; const doc = new FakeLspDocument({ uri: 'test://user-func-modifier.fish', languageId: 'fish', version: 1, text: content, }); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Find all my_func tokens const myFuncTokens = findTokensByText(tokens, 'my_func'); expect(myFuncTokens.length).toBeGreaterThan(0); // None should have defaultLibrary modifier myFuncTokens.forEach(token => { expect(token.modifiers).not.toContain('defaultLibrary'); }); }); }); describe('Token Deduplication', () => { it('should not create duplicate tokens at same position', () => { const content = `function test_func echo "test" end test_func`; const doc = createFakeLspDocument('test://dedup.fish', content); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Check that there are no exact duplicates (same line, char, type) const seen = new Set(); tokens.forEach(token => { const key = `${token.line}:${token.startChar}:${token.tokenType}`; expect(seen.has(key)).toBe(false); seen.add(key); }); }); it('should handle symbols and node tokens correctly', () => { // Test that both FishSymbol-based tokens and node-based tokens // are properly deduplicated const content = `set -l my_var "value" echo $my_var`; const doc = createFakeLspDocument( 'test://symbol-node.fish', content, ); analyzer.analyze(doc); const analyzed = analyzer.cache.getDocument(doc.uri)?.ensureParsed(); const result = getSemanticTokensSimplest(analyzed!, getRange(analyzed!.root)); const tokens = decodeSemanticTokens(result, content); // Should have tokens for my_var (from both symbol and expansion) const varTokens = findTokensByText(tokens, 'my_var'); expect(varTokens.length).toBeGreaterThan(0); expect(varTokens.every(t => t.tokenType === 'variable')).toBe(true); }); }); }); ================================================ FILE: tests/setup-mocks.ts ================================================ import { vi, expect } from 'vitest'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { env } from 'process'; // Define global fail function global.fail = (message?: string) => { expect.fail(message || 'Test failed'); }; // Use actual WASM files for tree-sitter functionality in tests vi.mock('web-tree-sitter/tree-sitter.wasm', () => ({ default: readFileSync(resolve(__dirname, '../node_modules/web-tree-sitter/tree-sitter.wasm')), })); vi.mock('@esdmr/tree-sitter-fish/tree-sitter-fish.wasm', () => ({ default: readFileSync(resolve(__dirname, '../node_modules/@esdmr/tree-sitter-fish/tree-sitter-fish.wasm')), })); // Legacy mocks for backward compatibility (if needed) vi.mock('@embedded_assets/tree-sitter-fish.wasm', () => ({ default: readFileSync(resolve(__dirname, '../node_modules/@esdmr/tree-sitter-fish/tree-sitter-fish.wasm')), })); vi.mock('@embedded_assets/tree-sitter.wasm', () => ({ default: readFileSync(resolve(__dirname, '../node_modules/web-tree-sitter/tree-sitter.wasm')), })); // Mock other assets vi.mock('@embedded_assets/man/fish-lsp.1', () => ({ default: readFileSync(resolve(__dirname, '../man/fish-lsp.1'), 'utf8'), })); // Use the actual build-time.json from the out directory vi.mock('@embedded_assets/build-time.json', () => { try { return { default: JSON.parse(readFileSync(resolve(__dirname, '../out/build-time.json'), 'utf8')) }; } catch (error) { // Fallback if build-time.json doesn't exist return { default: { buildTime: new Date().toISOString(), version: '1.0.0' } }; } }); // Mock path resolution functions to prevent incorrect file lookups in test environment vi.mock('../src/utils/path-resolution', async () => { const actual = await vi.importActual('../src/utils/path-resolution') as any; return { ...actual, getFishBuildTimeFilePath: () => resolve(__dirname, '../out/build-time.json'), getProjectRootPath: () => resolve(__dirname, '..'), getTreeSitterWasmPath: () => resolve(__dirname, '../node_modules/@esdmr/tree-sitter-fish/tree-sitter-fish.wasm'), }; }); // Mock process-env fish execution to prevent temp file errors in test environment vi.mock('../src/utils/process-env', async () => { const actual = await vi.importActual('../src/utils/process-env') as any; return { ...actual, }; }); ================================================ FILE: tests/snippets.test.ts ================================================ import { setLogger } from './helpers'; // import * as JsonObjs from '../src/utils/snippets' import { EnvVariableJson, ExtendedJson, fishLspObjs, fromCliOutputToString, fromCliToMarkdownString, getPrebuiltDocUrlByName, PrebuiltDocumentationMap } from '../src/utils/snippets'; import { handleEnvOutput } from '../src/config'; import { logger } from '../src/logger'; let prebuiltDocs = PrebuiltDocumentationMap; prebuiltDocs = PrebuiltDocumentationMap; describe('snippets tests', () => { setLogger(); describe('fish_lsp_* env variables', () => { it('should have fish_lsp_logfile', () => { const misses: ExtendedJson[] = []; for (const obj of fishLspObjs) { if (EnvVariableJson.is(obj)) { expect(EnvVariableJson.is(obj)).toBeTruthy(); } else { misses.push(obj); } } expect(misses).toHaveLength(0); }); it('print cli comment string', () => { for (const obj of fishLspObjs) { const cli = EnvVariableJson.asCliObject(obj); const output = fromCliOutputToString(cli); if (obj.name === 'fish_lsp_enabled_handlers') { expect(output.split('\n').at(0)!).toEqual('# $fish_lsp_enabled_handlers '); } else if (obj.name === 'fish_lsp_logfile') { expect(output.split('\n').at(0)!).toEqual('# $fish_lsp_logfile '); } else if (obj.name === 'fish_lsp_log_file') { expect(output.split('\n').at(0)!).toEqual('# $fish_lsp_log_file '); } } }); it('get all env variables', () => { prebuiltDocs.getByType('variable', 'fishlsp').forEach((v) => { expect(EnvVariableJson.is(v)).toBeTruthy(); }); }); it('get all cli output for `env`', () => { prebuiltDocs.getByType('variable', 'fishlsp').forEach((v) => { if (EnvVariableJson.is(v)) { const cli = EnvVariableJson.asCliObject(v); const output = fromCliOutputToString(cli); expect(output.split('\n').length).toBeGreaterThanOrEqual(4); } }); }); it('wrapped `env`', () => { for (const obj of fishLspObjs) { const cli = EnvVariableJson.asCliObject(obj); const output = fromCliOutputToString(cli, { includeDefaultValue: true, includeType: true, includeOptions: true, wrap: true }); console.log(output); console.log(); } }); it('env documentation', () => { for (const obj of fishLspObjs) { const cli = EnvVariableJson.asCliObject(obj); console.log(fromCliToMarkdownString(cli)); console.log(); } }); it('build in cli', () => { handleEnvOutput('create', logger.log); }); it('cli show', () => { handleEnvOutput('show', logger.log); }); }); // it('test 1: commands', async () => { // const out = JsonObjs.Snippets.commands() // const keys: string[] = [] // out.forEach((v) => { // keys.push(v.name) // }) // // console.log(out.size); // expect(out.has('if')).toBeTruthy() // expect(keys.includes('if')).toBeTruthy() // }) // // it('test 2: highlight variables', async () => { // const out = JsonObjs.Snippets.themeVars() // const keys: string[] = [] // // out.forEach(k => { // keys.push(k.name) // // console.log(k.name, k.description); // }) // // console.log('highlights: ', keys.join(', ')); // // console.log(); // expect(keys.find(k => k === 'fish_pager_color_progress')).toBeTruthy() // }) // // it('test 3: status numbers', async () => { // const out = JsonObjs.Snippets.status(); // const keys: string[] = [] // out.forEach(k => { // keys.push(k.name) // // console.log(k.name, k.description); // }) // // // console.log('status: ', keys.join(', ')); // // console.log(); // expect(keys.find(f => f === '0')).toBeTruthy() // expect(keys.find(f => f === '1')).toBeTruthy() // }) // // it('test 4: special vars', async () => { // const out = JsonObjs.Snippets.specialVars() // const keys: string[] = [] // out.forEach(k => { // keys.push(k.name) // // console.log(k.name, k.description); // }) // // console.log('special vars: ' ,keys.join(', ')); // // console.log(); // expect(keys.length).toBeGreaterThanOrEqual(50) // }) // // it('test 5: pipes', async () => { // const out = JsonObjs.Snippets.pipes() // const keys: string[] = [] // out.forEach(k => { // keys.push(k.name) // // console.log(k.name, k.description); // }) // // console.log('pipes:', keys.join(', ')); // // console.log(); // expect(Array.from(keys).length).toBeGreaterThanOrEqual(10) // }) // // it('test 6: userEnvVars', async () => { // const out = JsonObjs.Snippets.fishlspEnvVariables() // const keys: string[] = [] // out.forEach(k => { // keys.push(k.name) // // console.log(k.name, k.description); // }) // // console.log('userEnvVars:', keys.join(', ')); // // console.log(); // expect(Array.from(out.values()).length).toBeGreaterThanOrEqual(5) // }) // // it('test 7: print global export of variable', async () => { // const result = JsonObjs.printFromSnippetVariables(JsonObjs.Snippets.fishlspEnvVariables()) // expect(result.length).toBeGreaterThanOrEqual(15) // }) it('test 1: all prebuilt types', async () => { const commands = prebuiltDocs.getByType('command'); const pipes = prebuiltDocs.getByType('pipe'); const stats = prebuiltDocs.getByType('status'); const vars = prebuiltDocs.getByType('variable'); // console.log('amount seen', { // commands: commands.length, // pipes: pipes.length, // stats: stats.length, // vars: vars.length // }); expect(commands.length).toBeGreaterThan(100); expect(pipes.length).toBeGreaterThanOrEqual(13); expect(stats.length).toBeGreaterThanOrEqual(9); expect(vars.length).toBeGreaterThanOrEqual(90); }); it('test 2: matchingNames for theme variables', async () => { const color = prebuiltDocs.findMatchingNames('fish_color'); const pager = prebuiltDocs.findMatchingNames('fish_pager'); expect(color.length).toBeGreaterThan(20); expect(pager.length).toBeGreaterThan(10); }); it('test 3: check variable names with leading "$"', () => { expect(prebuiltDocs.getByName('$PATH')).toBeTruthy(); expect(prebuiltDocs.getByName('$fish_pager_color_background')).toBeTruthy(); }); it('test 4: check pipes', async () => { expect(prebuiltDocs.getByName('&>')).toBeTruthy(); expect(prebuiltDocs.getByName('>')).toBeTruthy(); expect(prebuiltDocs.getByName('>>')).toBeTruthy(); expect(prebuiltDocs.getByName('<')).toBeTruthy(); expect(prebuiltDocs.getByName('asdkfdsfdf').length).toBeFalsy(); }); it('test 5: check status numbers', async () => { expect(prebuiltDocs.getByName('0')).toBeTruthy(); expect(prebuiltDocs.getByName('1')).toBeTruthy(); expect(prebuiltDocs.getByName('121')).toBeTruthy(); expect(prebuiltDocs.getByName('123')).toBeTruthy(); expect(prebuiltDocs.getByName('124')).toBeTruthy(); expect(prebuiltDocs.getByName('125')).toBeTruthy(); expect(prebuiltDocs.getByName('126')).toBeTruthy(); expect(prebuiltDocs.getByName('127')).toBeTruthy(); expect(prebuiltDocs.getByName('128')).toBeTruthy(); }); it('test 6: check links/urls', async () => { // expect(getPrebuiltDocUrl(prebuiltDocs.getByName('0'))).toBeTruthy() // expect(getPrebuiltDocUrl(prebuiltDocs.getByName('fish_greeting'))).toEqual('https://fishshell.com/docs/current/cmds/fish_greeting.html') // console.log(getPrebuiltDocUrl(prebuiltDocs.getByName('abbr'))) // console.log(prebuiltDocs.getByName('fish_greeting')) expect(getPrebuiltDocUrlByName('fish_greeting').split('\n').length).toBeGreaterThan(1); // console.log(prebuiltDocs.findMatchingNames('fish_greeting')); // prebuiltDocs.getByName('fish_greeting') }); }); ================================================ FILE: tests/sourced-function-export.test.ts ================================================ import * as Parser from 'web-tree-sitter'; import { analyzer, Analyzer } from '../src/analyze'; import { LspDocument } from '../src/document'; import { initializeParser } from '../src/parser'; import { createSourceResources, SourceResource, symbolsFromResource } from '../src/parsing/source'; import { FishSymbol } from '../src/parsing/symbol'; import { createFakeLspDocument, setLogger } from './helpers'; import { workspaceManager } from '../src/utils/workspace-manager'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { Workspace } from '../src/utils/workspace'; describe('Sourced Function Export', () => { let parser: Parser; setLogger(); beforeEach(async () => { setupProcessEnvExecFile(); parser = await initializeParser(); await Analyzer.initialize(); await setupProcessEnvExecFile(); }); afterEach(() => { parser.delete(); workspaceManager.clear(); }); const continueOrExitPath = resolve(__dirname, '../scripts/fish/continue-or-exit.fish'); const prettyPrintPath = resolve(__dirname, '../scripts/fish/pretty-print.fish'); const publishNightlyPath = resolve(__dirname, '../scripts/publish-nightly.fish'); const continueOrExitContent = readFileSync(continueOrExitPath, 'utf8'); const prettyPrintContent = readFileSync(prettyPrintPath, 'utf8'); const publishNightlyContent = readFileSync(publishNightlyPath, 'utf8'); const continueOrExitDoc = createFakeLspDocument('scripts/fish/continue-or-exit.fish', continueOrExitContent); const prettyPrintDoc = createFakeLspDocument('scripts/fish/pretty-print.fish', prettyPrintContent); const publishNightlyDoc = createFakeLspDocument('scripts/publish-nightly.fish', publishNightlyContent); test('should handle real script files with sourcing', () => { // Read the actual files from the repository // Create documents using the real file content // Analyze all documents analyzer.analyze(continueOrExitDoc); analyzer.analyze(prettyPrintDoc); analyzer.analyze(publishNightlyDoc); // Test continue_or_exit.fish symbols const continueOrExitSymbols = Array.from(analyzer.getFlatDocumentSymbols(continueOrExitDoc.uri)); // Should have the main function const continueOrExitFunction = continueOrExitSymbols.find(s => s.name === 'continue_or_exit'); expect(continueOrExitFunction).toBeDefined(); expect(continueOrExitFunction!.isFunction()).toBe(true); expect(continueOrExitFunction!.isRootLevel()).toBe(true); expect(continueOrExitFunction!.parent).toBeUndefined(); // Should have the helper function const printTextFunction = continueOrExitSymbols.find(s => s.name === 'print_text_with_color'); expect(printTextFunction).toBeDefined(); expect(printTextFunction!.isFunction()).toBe(true); expect(printTextFunction!.isRootLevel()).toBe(true); expect(printTextFunction!.parent).toBeUndefined(); // Test pretty-print.fish symbols const prettyPrintSymbols = Array.from(analyzer.getFlatDocumentSymbols(prettyPrintDoc.uri)); // Should have global color variables const greenVar = prettyPrintSymbols.find(s => s.name === 'GREEN'); expect(greenVar).toBeDefined(); expect(greenVar!.isVariable()).toBe(true); expect(greenVar!.isRootLevel()).toBe(true); expect(greenVar!.parent).toBeUndefined(); // Should have utility functions const resetColorFunction = prettyPrintSymbols.find(s => s.name === 'reset_color'); expect(resetColorFunction).toBeDefined(); expect(resetColorFunction!.isFunction()).toBe(true); expect(resetColorFunction!.isRootLevel()).toBe(true); // Test symbolic linking with mock resources const mockContinueOrExitResource = { to: continueOrExitDoc, from: publishNightlyDoc, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, node: {} as any, definitionScope: {} as any, sources: [], } as unknown as SourceResource; const mockPrettyPrintResource = { to: prettyPrintDoc, from: publishNightlyDoc, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, node: {} as any, definitionScope: {} as any, sources: [], } as unknown as SourceResource; // Test symbolsFromResource with continue_or_exit.fish const exportedContinueOrExitSymbols = symbolsFromResource(analyzer, mockContinueOrExitResource); const exportedContinueOrExitNames = exportedContinueOrExitSymbols.map(s => s.name); expect(exportedContinueOrExitNames).toContain('continue_or_exit'); expect(exportedContinueOrExitNames).toContain('print_text_with_color'); // Test symbolsFromResource with pretty-print.fish const exportedPrettyPrintSymbols = symbolsFromResource(analyzer, mockPrettyPrintResource); const exportedPrettyPrintNames = exportedPrettyPrintSymbols.map(s => s.name); expect(exportedPrettyPrintNames).toContain('GREEN'); expect(exportedPrettyPrintNames).toContain('RED'); expect(exportedPrettyPrintNames).toContain('BLUE'); expect(exportedPrettyPrintNames).toContain('reset_color'); expect(exportedPrettyPrintNames).toContain('print_success'); expect(exportedPrettyPrintNames).toContain('print_failure'); // Verify exported symbols are either root level OR global const allExportedSymbols = [...exportedContinueOrExitSymbols, ...exportedPrettyPrintSymbols]; // The symbolsFromResource function should return symbols that are either: // 1. Root level (no parent), OR // 2. Global variables (accessible globally even if defined in functions) for (const symbol of allExportedSymbols) { const isValidExport = symbol.isRootLevel() || symbol.isGlobal(); if (!isValidExport) { console.log(`Invalid export: ${symbol.name} (${symbol.fishKind}) - Parent: ${symbol.parent?.name}, Global: ${symbol.isGlobal()}, RootLevel: ${symbol.isRootLevel()}`); } expect(isValidExport).toBe(true); } // Specifically check that CONTINUE_OR_EXIT_ANSWER is included as a global variable const continueOrExitAnswer = allExportedSymbols.find(s => s.name === 'CONTINUE_OR_EXIT_ANSWER'); expect(continueOrExitAnswer).toBeDefined(); expect(continueOrExitAnswer!.isGlobal()).toBe(true); expect(continueOrExitAnswer!.isRootLevel()).toBe(false); // It has a parent function }); test('should correctly identify root level vs nested symbols', () => { // Create a script with nested and top-level symbols const testScript = `#!/usr/bin/env fish function top_level_function echo "I'm at the top level" function nested_function echo "I'm nested" end set -l function_local "function local" end set -g global_var "global value" set -l script_local "script local" `; // Create and analyze document const testDoc = createFakeLspDocument('test.fish', testScript); analyzer.analyze(testDoc); // Get all symbols from the document const allSymbols = Array.from(analyzer.getFlatDocumentSymbols(testDoc.uri)); // Test top-level function const topLevelFunction = allSymbols.find(s => s.name === 'top_level_function'); expect(topLevelFunction).toBeDefined(); expect(topLevelFunction!.isRootLevel()).toBe(true); expect(topLevelFunction!.parent).toBeUndefined(); // Test nested function const nestedFunction = allSymbols.find(s => s.name === 'nested_function'); expect(nestedFunction).toBeDefined(); expect(nestedFunction!.isRootLevel()).toBe(false); expect(nestedFunction!.parent).toBeDefined(); expect(nestedFunction!.parent!.name).toBe('top_level_function'); // Test global variable const globalVar = allSymbols.find(s => s.name === 'global_var'); expect(globalVar).toBeDefined(); expect(globalVar!.isRootLevel()).toBe(true); expect(globalVar!.parent).toBeUndefined(); // Test script-local variable const scriptLocal = allSymbols.find(s => s.name === 'script_local'); expect(scriptLocal).toBeDefined(); expect(scriptLocal!.isRootLevel()).toBe(true); expect(scriptLocal!.parent).toBeUndefined(); // Test function-local variable const functionLocal = allSymbols.find(s => s.name === 'function_local'); expect(functionLocal).toBeDefined(); expect(functionLocal!.isRootLevel()).toBe(false); expect(functionLocal!.parent).toBeDefined(); expect(functionLocal!.parent!.name).toBe('top_level_function'); // Test symbolsFromResource filtering const mockSourceResource = { to: testDoc, from: testDoc, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, node: {} as any, definitionScope: {} as any, sources: [], }; const sources = analyzer.collectAllSources(testDoc.uri); const resource = createSourceResources(analyzer, testDoc); const collection: FishSymbol[] = [...allSymbols.filter(s => s.isRootLevel() || s.isGlobal())]; for (const res of resource) { analyzer.analyze(res.to); collection.push(...symbolsFromResource(analyzer, res, new Set(collection.map(s => s.name)))); } // const exportedSymbols = symbolsFromResource(analyzer); // const exportedNames = exportedSymbols.map(s => s.name); // Should export top-level symbols expect(collection.map(c => c.name)).toContain('top_level_function'); expect(collection.map(c => c.name)).toContain('global_var'); expect(collection.map(c => c.name)).toContain('script_local'); // collection.map(c => c.name); // Shoucollection.map(c => c.name) nested symbols expect(collection.map(c => c.name)).not.toContain('nested_function'); expect(collection.map(c => c.name)).not.toContain('function_local'); }); test('should handle deeply nested symbols correctly', () => { const deeplyNestedScript = `#!/usr/bin/env fish function level1 function level2 function level3 echo "deeply nested" end end end function root_level echo "at the root" end `; const doc = createFakeLspDocument('nested.fish', deeplyNestedScript); analyzer.analyze(doc); const allSymbols = Array.from(analyzer.getFlatDocumentSymbols(doc.uri)); // Check level1 (root) const level1 = allSymbols.find(s => s.name === 'level1'); expect(level1).toBeDefined(); expect(level1!.isRootLevel()).toBe(true); expect(level1!.parent).toBeUndefined(); // Check level2 (child of level1) const level2 = allSymbols.find(s => s.name === 'level2'); expect(level2).toBeDefined(); expect(level2!.isRootLevel()).toBe(false); expect(level2!.parent).toBeDefined(); expect(level2!.parent!.name).toBe('level1'); // Check level3 (child of level2) const level3 = allSymbols.find(s => s.name === 'level3'); expect(level3).toBeDefined(); expect(level3!.isRootLevel()).toBe(false); expect(level3!.parent).toBeDefined(); expect(level3!.parent!.name).toBe('level2'); // Check root_level (root) const rootLevel = allSymbols.find(s => s.name === 'root_level'); expect(rootLevel).toBeDefined(); expect(rootLevel!.isRootLevel()).toBe(true); expect(rootLevel!.parent).toBeUndefined(); // Test symbolsFromResource with deeply nested structure const mockSourceResource = { to: doc, from: doc, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, node: {} as any, definitionScope: {} as any, sources: [], }; // const exportedSymbols = symbolsFromResource(analyzer, mockSourceResource); // const exportedNames = exportedSymbols.map(s => s.name); const exportedNames: string[] = [...allSymbols.filter(s => s.isRootLevel()).map(s => s.name)]; for (const res of createSourceResources(analyzer, doc)) { analyzer.analyze(res.to); const exportedSymbols = symbolsFromResource(analyzer, res, new Set(exportedNames)); exportedNames.push(...exportedSymbols.map(s => s.name)); } // Should only export root-level symbols expect(exportedNames).toContain('level1'); expect(exportedNames).toContain('root_level'); expect(exportedNames).not.toContain('level2'); expect(exportedNames).not.toContain('level3'); }); test('should include sourced symbols in analyzer collectSourcedSymbols method', () => { // Read actual helper files first to get their paths // Create a main script that sources other files using absolute paths const mainScript = `#!/usr/bin/env fish # Source the helper files using absolute paths source ${continueOrExitPath} source ${prettyPrintPath} function main_function continue_or_exit "Do you want to continue?" print_success "Operation completed" end set -g MAIN_VAR "main variable" `; // Create documents const mainDoc = createFakeLspDocument('scripts/main.fish', mainScript); // Analyze all documents analyzer.analyze(mainDoc); analyzer.analyze(continueOrExitDoc); analyzer.analyze(prettyPrintDoc); // Test the collectSourcedSymbols method const sourcedSymbols = analyzer.collectSourcedSymbols(mainDoc.uri); const sourcedNames = sourcedSymbols.map(s => s.name); // Should include sourced functions from continue_or_exit.fish expect(sourcedNames).toContain('continue_or_exit'); expect(sourcedNames).toContain('print_text_with_color'); // Should include sourced functions and variables from pretty-print.fish expect(sourcedNames).toContain('GREEN'); expect(sourcedNames).toContain('RED'); expect(sourcedNames).toContain('BLUE'); expect(sourcedNames).toContain('reset_color'); expect(sourcedNames).toContain('print_success'); expect(sourcedNames).toContain('print_failure'); // Should include global variables from continue_or_exit.fish expect(sourcedNames).toContain('CONTINUE_OR_EXIT_ANSWER'); // Verify that local symbols from main script are NOT included (they should come from getDocumentSymbols) expect(sourcedNames).not.toContain('main_function'); expect(sourcedNames).not.toContain('MAIN_VAR'); // Verify that all sourced symbols are exportable (root level or global) for (const symbol of sourcedSymbols) { expect(symbol.isRootLevel() || symbol.isGlobal()).toBe(true); } }); test('should integrate sourced symbols with server onDocumentSymbols', () => { // Read helper file first // Create a main script that sources helper files using absolute path const mainScript = `#!/usr/bin/env fish source ${continueOrExitPath} function main_function continue_or_exit "test" end set -g MAIN_VAR "main" `; // Create documents const mainDoc = createFakeLspDocument('scripts/main.fish', mainScript); const continueOrExitDoc = createFakeLspDocument(continueOrExitPath, continueOrExitContent); // Analyze documents analyzer.analyze(mainDoc); analyzer.analyze(continueOrExitDoc); // Get local symbols only (current behavior) const localSymbols = analyzer.cache.getDocumentSymbols(mainDoc.uri); const localNames = localSymbols.map(s => s.name); // Get sourced symbols const sourcedSymbols = analyzer.collectSourcedSymbols(mainDoc.uri); const sourcedNames = sourcedSymbols.map(s => s.name); // Verify local symbols contain main script definitions expect(localNames).toContain('main_function'); expect(localNames).toContain('MAIN_VAR'); // Verify sourced symbols contain sourced definitions expect(sourcedNames).toContain('continue_or_exit'); expect(sourcedNames).toContain('print_text_with_color'); expect(sourcedNames).toContain('CONTINUE_OR_EXIT_ANSWER'); // Verify no overlap between local and sourced (except for common variables like argv) const commonVariables = ['argv']; // These can appear in both local and sourced for (const localName of localNames) { if (!commonVariables.includes(localName)) { expect(sourcedNames).not.toContain(localName); } } // Combined symbols should include both const allSymbols = [...localSymbols, ...sourcedSymbols]; const allNames = allSymbols.map(s => s.name); expect(allNames).toContain('main_function'); // from local expect(allNames).toContain('MAIN_VAR'); // from local expect(allNames).toContain('continue_or_exit'); // from sourced expect(allNames).toContain('print_text_with_color'); // from sourced expect(allNames).toContain('CONTINUE_OR_EXIT_ANSWER'); // from sourced // Verify the combination logic works (allowing for common duplicates like argv) const uniqueNames = new Set(); const duplicateNames = new Set(); for (const symbol of allSymbols) { if (uniqueNames.has(symbol.name)) { duplicateNames.add(symbol.name); } uniqueNames.add(symbol.name); } // Only common variables should be duplicated // const allowedDuplicates = ['argv', 'reset_color']; expect(duplicateNames).toContain('argv'); }); test('should find sourced functions in allSymbolsAccessibleAtPosition', () => { // Create a main script that sources pretty-print and uses log_info const mainScript = `#!/usr/bin/env fish source ${prettyPrintPath} function main_function log_info "test" "message" "content" print_success "done" end `; // Create documents const mainDoc = createFakeLspDocument('scripts/main.fish', mainScript); const prettyPrintDoc = createFakeLspDocument(prettyPrintPath, prettyPrintContent); // Analyze documents analyzer.analyze(mainDoc); analyzer.analyze(prettyPrintDoc); // Get symbols accessible at the position where log_info is called (line 5) const position = { line: 5, character: 4 }; // Inside the function where log_info is called const accessibleSymbols = analyzer.allSymbolsAccessibleAtPosition(mainDoc, position); const accessibleNames = accessibleSymbols.map(s => s.name); // Should include sourced functions from pretty-print.fish expect(accessibleNames).toContain('log_info'); expect(accessibleNames).toContain('print_success'); expect(accessibleNames).toContain('reset_color'); // Should include sourced variables from pretty-print.fish expect(accessibleNames).toContain('GREEN'); expect(accessibleNames).toContain('BLUE'); expect(accessibleNames).toContain('NORMAL'); // Should include local function expect(accessibleNames).toContain('main_function'); // Verify that we can find the log_info symbol specifically const logInfoSymbol = accessibleSymbols.find(s => s.name === 'log_info'); expect(logInfoSymbol).toBeDefined(); expect(logInfoSymbol!.isFunction()).toBe(true); expect(logInfoSymbol!.uri).toBe(prettyPrintDoc.uri); expect(logInfoSymbol!.isRootLevel()).toBe(true); }); test('should resolve definition for sourced functions correctly', () => { // Create a main script that sources pretty-print and uses log_info const prettyPrintPath = resolve(__dirname, '../scripts/fish/pretty-print.fish'); const prettyPrintContent = readFileSync(prettyPrintPath, 'utf8'); const mainScript = `#!/usr/bin/env fish source ${prettyPrintPath} function main_function log_info "test" "message" "content" end `; // Create documents const mainDoc = createFakeLspDocument('scripts/main.fish', mainScript); const prettyPrintDoc = createFakeLspDocument(prettyPrintPath, prettyPrintContent); // Analyze documents analyzer.analyze(mainDoc); analyzer.analyze(prettyPrintDoc); // Get definition at the position of "log_info" call (line 5, character 4) const position = { line: 5, character: 4 }; const definition = analyzer.getDefinition(mainDoc, position); // Should find the log_info function definition from pretty-print.fish expect(definition).toBeDefined(); expect(definition!.name).toBe('log_info'); expect(definition!.isFunction()).toBe(true); expect(definition!.uri).toBe(prettyPrintDoc.uri); expect(definition!.isRootLevel()).toBe(true); }); // TODO: reenable this test, skipping because we restructured publish-nightly.fish test.skip('should resolve publish-nightly.fish log_info function call', async () => { // Test the exact use case from the user's example // Create a modified version of publish-nightly.fish with absolute paths for sourcing const modifiedPublishNightlyContent = publishNightlyContent .replace('source ./scripts/fish/continue-or-exit.fish', `source ${continueOrExitPath}`) .replace('source ./scripts/fish/pretty-print.fish', `source ${prettyPrintPath}`); // Create documents using the real file paths const publishNightlyDoc = createFakeLspDocument(publishNightlyPath, modifiedPublishNightlyContent); // Analyze documents analyzer.analyze(publishNightlyDoc); analyzer.analyze(prettyPrintDoc); analyzer.analyze(continueOrExitDoc); workspaceManager.current?.addDocument(publishNightlyDoc); workspaceManager.current?.addDocument(prettyPrintDoc); workspaceManager.current?.addDocument(continueOrExitDoc); workspaceManager.current?.setAllPending(); await workspaceManager.analyzePendingDocuments(); // Find a log_info call in publish-nightly.fish (line 41, character 4) const position = { line: 40, character: 4 }; // Line 41 in 0-indexed (log_info call) // Test allSymbolsAccessibleAtPosition includes log_info const accessibleSymbols = analyzer.allSymbolsAccessibleAtPosition(publishNightlyDoc, position); const accessibleNames = accessibleSymbols.map(s => s.name); expect(accessibleNames).toContain('log_info'); // Test getDefinition can find log_info const definition = analyzer.getDefinition(publishNightlyDoc, position); expect(definition).toBeDefined(); expect(definition!.name).toBe('log_info'); expect(definition!.isFunction()).toBe(true); expect(definition!.uri).toBe(prettyPrintDoc.uri); // Verify it finds the correct definition location (log_info is at line 98 in pretty-print.fish) expect(definition!.selectionRange.start.line).toBe(98); // 0-indexed }); test('should resolve relative paths in source commands', () => { // Create a script that uses relative paths like the real publish-nightly.fish const mainScript = `#!/usr/bin/env fish # Use relative paths like in the real files source ./scripts/fish/pretty-print.fish function test_function log_info "test" "Testing relative path resolution" end `; // Read the actual pretty-print.fish file // Create documents - the main script will be in the project root so relative paths work const mainDoc = createFakeLspDocument(resolve(__dirname, '../main.fish'), mainScript); const prettyPrintDoc = createFakeLspDocument(prettyPrintPath, prettyPrintContent); // Analyze documents analyzer.analyze(mainDoc); analyzer.analyze(prettyPrintDoc); // Test that relative path resolution works const position = { line: 5, character: 4 }; // Inside test_function where log_info is called const accessibleSymbols = analyzer.allSymbolsAccessibleAtPosition(mainDoc, position); const accessibleNames = accessibleSymbols.map(s => s.name); // Should include the log_info function from the relatively sourced file expect(accessibleNames).toContain('log_info'); // Since allSymbolsAccessibleAtPosition works, the relative path resolution is successful! // Let's verify that log_info is correctly from the pretty-print file const logInfoSymbol = accessibleSymbols.find(s => s.name === 'log_info'); expect(logInfoSymbol).toBeDefined(); expect(logInfoSymbol!.uri).toBe(prettyPrintDoc.uri); expect(logInfoSymbol!.isFunction()).toBe(true); // Test getDefinition for the relatively sourced function // Note: getDefinition might have a different issue that we can address separately const definition = analyzer.getDefinition(mainDoc, position); if (definition) { expect(definition.name).toBe('log_info'); expect(definition.uri).toBe(prettyPrintDoc.uri); } else { // For now, we'll accept that allSymbolsAccessibleAtPosition works correctly // The relative path resolution is working, which is the main goal } }); describe('scripts/publish-nightly.fish', () => { const document = publishNightlyDoc; let ws: Workspace | null = null; beforeEach(async () => { workspaceManager.clear(); ws = workspaceManager.handleOpenDocument(document)!; workspaceManager.handleUpdateDocument(document); workspaceManager.setCurrent(ws); analyzer.analyze(document); analyzer.ensureCachedDocument(document); }); afterEach(() => { if (ws) { workspaceManager.handleCloseDocument(document.uri); ws = null; } }); it('should find all symbols in publish-nightly.fish', () => { console.log({ document: document.uri, path: document.path, content: document.getText(), }); const sourcedSymbols = analyzer.collectSourcedSymbols(document.uri); console.log({ sourcedSymbols: sourcedSymbols.map(s => s.name), }); }); }); }); ================================================ FILE: tests/startup-workspace.test.ts ================================================ // import * as fs from 'fs'; import * as os from 'os'; import { fail, setLogger } from './helpers'; import { FishUriWorkspace, initializeDefaultFishWorkspaces } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { Config, config, ConfigSchema } from '../src/config'; import { uriToPath } from '../src/utils/translation'; import * as LSP from 'vscode-languageserver'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; // import { Logger } from '../src/logger'; // import { SyncFileHelper } from '../src/utils/file-operations'; // Mock the entire fs module // jest.mock('fs'); describe('setup workspace', () => { setLogger(); beforeAll(async () => { await setupProcessEnvExecFile(); }); // resetting the config object before each test beforeEach(() => { // Create a fresh default config const defaultConfig = ConfigSchema.parse({}); // Reset all properties of the imported config object // Reset all properties of the imported config object with proper typing (Object.keys(config) as Array).forEach(key => { delete config[key]; }); // Copy default values into the config object Object.assign(config, defaultConfig); }); afterEach(() => { config.fish_lsp_all_indexed_paths = []; workspaceManager.clear(); }); describe('fisher workspace', () => { it('conf.d/fisher-template', () => { const params = { rootUri: 'file:///home/ndonfris/repos/fisher-template/conf.d', rootPath: '/home/ndonfris/repos/fisher-template/conf.d', workspaceFolders: [ { uri: 'file:///home/ndonfris/repos/fisher-template/conf.d', name: 'conf.d', }, ], } as LSP.InitializeParams; const workspaceUri = uriToPath(params.rootUri!); const workspacePath = uriToPath(params.rootPath!); expect(workspaceUri).toBeTruthy(); expect(workspacePath).toBeTruthy(); // console.log(`workspaceUri: ${workspaceUri}`); // console.log(`workspacePath: ${workspacePath}`); const uris = [ 'file:///home/user/repos/fisher-template/conf.d', 'file:///home/user/repos/fisher-template/functions', 'file:///home/user/repos/fisher-template/completions', 'file:///home/user/repos/fisher-template/config.fish', 'file:///usr/share/fish/config.fish', 'file:///usr/share/fish/completions/file.fish', 'file:///usr/share/fish/functions/file.fish', 'file:///usr/share/fish/conf.d/file.fish', 'file:///home/user/.config/fish/conf.d/file.fish', 'file:///home/user/.config/fish/config.fish', 'file:///home/user/.config/fish/functions/file.fish', 'file:///home/user/.config/fish/conf.d/file.fish', 'file:///home/user/some/random/folder/script.fish', ]; for (const inputUri of uris) { const fishWorkspace = FishUriWorkspace.create(inputUri)!; if (!fishWorkspace) fail(); const { name, uri, path } = fishWorkspace; expect(name).toBeTruthy(); expect(uri).toBeTruthy(); expect(path).toBeTruthy(); // console.log({ inputUri, name, uri, path }); } }); }); describe('fisher workspace w/ $fish_lsp_single_workspace_support \'true\'', () => { it('conf.d/fisher-template', async () => { // config.fish_lsp_single_workspace_support = true; const uris = [ `file://${os.homedir()}/repos/fisher-template/conf.d`, `file://${os.homedir()}/repos/fisher-template`, `file://${os.homedir()}/repos/fzf.fish/functions`, `file://${os.homedir()}/repos/bends.fish`, /** assuming this exists */ ]; // let i = 0; for (const inputUri of uris) { // const root = FishUriWorkspace.getWorkspaceRootFromUri(inputUri); const workspace = FishUriWorkspace.create(inputUri); // console.log('fisher-template', i, { // root, // workspace: { // ...workspace // }, // isDirectory: SyncFileHelper.isDirectory(root?.toString() || ''), // }); // i++; expect(workspace).toBeDefined(); expect([ `file://${os.homedir()}/repos/fisher-template`, `file://${os.homedir()}/repos/fisher-template`, `file://${os.homedir()}/repos/fzf.fish`, `file://${os.homedir()}/repos/bends.fish`, ].includes(workspace!.uri)).toBeTruthy(); } expect(true).toBe(true); }); }); describe('`config.fish_lsp_single_workspace_support` updating during startup', () => { it(`file://${os.homedir()}/.config/fish \`false -> false\``, async () => { config.fish_lsp_single_workspace_support = false; const uri = `file://${os.homedir()}/.config/fish`; const workspaces = await initializeDefaultFishWorkspaces(uri); expect(workspaces.length).toBe(2); expect(config.fish_lsp_single_workspace_support).toBe(false); }); it(`file://${os.homedir()}/.config/fish \`false -> false\``, async () => { config.fish_lsp_single_workspace_support = true; config.fish_lsp_all_indexed_paths = [`${os.homedir()}/.config/fish`]; const uri = `file://${os.homedir()}/.config/fish`; const workspaces = await initializeDefaultFishWorkspaces(uri); workspaces.forEach((ws, i) => { console.log(`(${i}) workspace`, ws.uri); }); expect(workspaces.length).toBe(1); expect(config.fish_lsp_single_workspace_support).toBe(true); }); // it('/tmp/foo.fish \`true -> false\`', async () => { // config.fish_lsp_single_workspace_support = true; // const uri = 'file:///tmp'; // const workspaces = await initializeDefaultFishWorkspaces(uri); // expect(workspaces.length).toBe(3); // expect(config.fish_lsp_single_workspace_support).toBe(false); // }); }); describe('/tmp testing of workspaces', () => { it('/tmp/foo.fish', async () => { const uri = 'file:///tmp/foo.fish'; // // const workspaceRoot = FishUriWorkspace.getWorkspaceRootFromUri(uri); // const workspaceName = FishUriWorkspace.getWorkspaceName(uri); // const workspace = FishUriWorkspace.create(uri); // console.log('/tmp workspaceRoot', { // workspaceRoot, // workspaceName, // workspace: workspace?.uri, // isFile: SyncFileHelper.isFile(workspaceRoot?.toString() || ''), // }); console.log('/tmp/foo.fish'); const workspaces = await initializeDefaultFishWorkspaces(uri); // console.log({ // workspaces: workspaces.map(w => w.uri), // }); expect(workspaces.length).toBe(3); expect(workspaces.map(w => w.uri).includes('file:///tmp/foo.fish')).toBeTruthy(); }); }); }); // export interface FishUriWorkspace { // name: string; // uri: string; // } // // export namespace FishUriWorkspace { // // /** special location names */ // const FISH_DIRS = ['functions', 'completions', 'conf.d']; // const CONFIG_FILE = 'config.fish'; // // /** // * Removes file path component from a fish file URI unless it's config.fish // */ // function trimFishFilePath(uri: string): string | undefined { // const path = uriToPath(uri); // if (!path) return undefined; // // const base = basename(path); // if (base === CONFIG_FILE) return path; // return base.endsWith('.fish') ? dirname(path) : path; // } // // /** // * Gets the workspace root directory from a URI // */ // function getWorkspaceRootFromUri(uri: string): string | undefined { // const path = uriToPath(uri); // if (!path) return undefined; // // let current = path; // const base = basename(current); // // // If path is a fish directory or config.fish, return parent // if (FISH_DIRS.includes(base) || base === CONFIG_FILE) { // return dirname(current); // } // // // Walk up looking for fish workspace indicators // while (current !== dirname(current)) { // // Check for fish dirs in current directory // for (const dir of FISH_DIRS) { // if (basename(current) === dir) { // return dirname(current); // } // } // // // Check for config.fish or fish dirs as children // if (FISH_DIRS.some(dir => isFishWorkspacePath(join(current, dir))) || // isFishWorkspacePath(join(current, CONFIG_FILE))) { // return current; // } // // current = dirname(current); // } // // // Check if we're in a configured path // return config.fish_lsp_all_indexed_paths.find(p => path.startsWith(p)); // } // // /** // * Gets a human-readable name for the workspace root // */ // function getWorkspaceName(uri: string): string { // const root = getWorkspaceRootFromUri(uri); // if (!root) return ''; // // // Special cases for system directories // if (root.endsWith('/.config/fish')) return '__fish_config_dir'; // const specialName = autoloadedFishVariableNames.find(loadedName => process.env[loadedName] === root); // if (specialName) return specialName; // // if (root === '/usr/share/fish') return '__fish_data_dir'; // // // For other paths, return the workspace root's basename // return basename(root); // } // // /** // * Checks if a path indicates a fish workspace // */ // function isFishWorkspacePath(path: string): boolean { // return config.fish_lsp_all_indexed_paths.includes(path) || // FISH_DIRS.includes(basename(path)) || basename(path) === CONFIG_FILE; // } // // /** // * Determines if a URI is within a fish workspace // */ // function isInFishWorkspace(uri: string): boolean { // return getWorkspaceRootFromUri(uri) !== undefined; // } // // /** // * Creates a FishUriWorkspace from a URI // * @returns null if the URI is not in a fish workspace, otherwise the workspace // */ // export function create(uri: string): FishUriWorkspace | null { // // if (!isInFishWorkspace(uri)) return null; // // const trimmedUri = trimFishFilePath(uri) // if (!trimmedUri) return null; // // const rootUri = getWorkspaceRootFromUri(trimmedUri) // const workspaceName = getWorkspaceName(trimmedUri) // // if (!rootUri || !workspaceName) return null; // // return { // name: workspaceName, // uri: rootUri, // }; // } // } ================================================ FILE: tests/symbol-root-level.test.ts ================================================ import { describe, expect, test, beforeAll } from 'vitest'; import * as Parser from 'web-tree-sitter'; import { Analyzer } from '../src/analyze'; import { LspDocument } from '../src/document'; import { initializeParser } from '../src/parser'; import { symbolsFromResource } from '../src/parsing/source'; import { setLogger } from './helpers'; describe('Symbol Root Level Detection', () => { let parser: Parser; let analyzer: Analyzer; setLogger(); beforeAll(async () => { parser = await initializeParser(); analyzer = await Analyzer.initialize(); }); test('should correctly identify root level vs nested symbols', () => { // Create a script with nested and top-level symbols const testScript = `#!/usr/bin/env fish function top_level_function echo "I'm at the top level" function nested_function echo "I'm nested" end set -l function_local "function local" end set -g global_var "global value" set -l script_local "script local" `; // Create and analyze document const testDoc = LspDocument.createTextDocumentItem('file:///test.fish', testScript); analyzer.analyze(testDoc); // Get all symbols from the document const allSymbols = Array.from(analyzer.getFlatDocumentSymbols(testDoc.uri)); // Test top-level function const topLevelFunction = allSymbols.find(s => s.name === 'top_level_function'); expect(topLevelFunction).toBeDefined(); expect(topLevelFunction!.isRootLevel()).toBe(true); expect(topLevelFunction!.parent).toBeUndefined(); // Test nested function const nestedFunction = allSymbols.find(s => s.name === 'nested_function'); expect(nestedFunction).toBeDefined(); expect(nestedFunction!.isRootLevel()).toBe(false); expect(nestedFunction!.parent).toBeDefined(); expect(nestedFunction!.parent!.name).toBe('top_level_function'); // Test global variable const globalVar = allSymbols.find(s => s.name === 'global_var'); expect(globalVar).toBeDefined(); expect(globalVar!.isRootLevel()).toBe(true); expect(globalVar!.parent).toBeUndefined(); // Test script-local variable const scriptLocal = allSymbols.find(s => s.name === 'script_local'); expect(scriptLocal).toBeDefined(); expect(scriptLocal!.isRootLevel()).toBe(true); expect(scriptLocal!.parent).toBeUndefined(); // Test function-local variable const functionLocal = allSymbols.find(s => s.name === 'function_local'); expect(functionLocal).toBeDefined(); expect(functionLocal!.isRootLevel()).toBe(false); expect(functionLocal!.parent).toBeDefined(); expect(functionLocal!.parent!.name).toBe('top_level_function'); }); test('should filter symbols correctly in symbolsFromResource', () => { // Create a script with both exportable and non-exportable symbols const sourceScript = `#!/usr/bin/env fish function exportable_function echo "I should be exported" function nested_function echo "I should NOT be exported" end set -l function_scoped "I should NOT be exported" end function another_exportable echo "I should also be exported" end set -g global_var "I should be exported" set -l root_local "I should be exported" `; // Create and analyze document const sourceDoc = LspDocument.createTextDocumentItem('file:///source.fish', sourceScript); analyzer.analyze(sourceDoc); // Create a mock SourceResource const mockSourceResource = { to: sourceDoc, from: sourceDoc, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, node: {} as any, definitionScope: {} as any, sources: [], }; // Get exported symbols using symbolsFromResource const exportedSymbols = symbolsFromResource(analyzer, mockSourceResource); const exportedNames = exportedSymbols.map(s => s.name); // Should export root-level functions expect(exportedNames).toContain('exportable_function'); expect(exportedNames).toContain('another_exportable'); // Should export root-level variables expect(exportedNames).toContain('global_var'); expect(exportedNames).toContain('root_local'); // Should NOT export nested functions expect(exportedNames).not.toContain('nested_function'); // Should NOT export function-scoped variables expect(exportedNames).not.toContain('function_scoped'); // Verify all exported symbols are indeed root level for (const symbol of exportedSymbols) { expect(symbol.isRootLevel()).toBe(true); } }); test('should handle deeply nested symbols correctly', () => { const deeplyNestedScript = `#!/usr/bin/env fish function level1 function level2 function level3 echo "deeply nested" end end end function root_level echo "at the root" end `; const doc = LspDocument.createTextDocumentItem('file:///nested.fish', deeplyNestedScript); analyzer.analyze(doc); const allSymbols = Array.from(analyzer.getFlatDocumentSymbols(doc.uri)); // Check level1 (root) const level1 = allSymbols.find(s => s.name === 'level1'); expect(level1).toBeDefined(); expect(level1!.isRootLevel()).toBe(true); expect(level1!.parent).toBeUndefined(); // Check level2 (child of level1) const level2 = allSymbols.find(s => s.name === 'level2'); expect(level2).toBeDefined(); expect(level2!.isRootLevel()).toBe(false); expect(level2!.parent).toBeDefined(); expect(level2!.parent!.name).toBe('level1'); // Check level3 (child of level2) const level3 = allSymbols.find(s => s.name === 'level3'); expect(level3).toBeDefined(); expect(level3!.isRootLevel()).toBe(false); expect(level3!.parent).toBeDefined(); expect(level3!.parent!.name).toBe('level2'); // Check root_level (root) const rootLevel = allSymbols.find(s => s.name === 'root_level'); expect(rootLevel).toBeDefined(); expect(rootLevel!.isRootLevel()).toBe(true); expect(rootLevel!.parent).toBeUndefined(); // Test symbolsFromResource with deeply nested structure const mockSourceResource = { to: doc, from: doc, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, node: {} as any, definitionScope: {} as any, sources: [], }; const exportedSymbols = symbolsFromResource(analyzer, mockSourceResource); const exportedNames = exportedSymbols.map(s => s.name); // Should only export root-level symbols expect(exportedNames).toContain('level1'); expect(exportedNames).toContain('root_level'); expect(exportedNames).not.toContain('level2'); expect(exportedNames).not.toContain('level3'); }); }); ================================================ FILE: tests/temp.ts ================================================ import { writeFileSync, unlinkSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { Workspace } from '../src/utils/workspace'; import { LspDocument } from '../src/document'; import { TextDocumentItem } from 'vscode-languageserver'; import { workspaceManager } from '../src/utils/workspace-manager'; interface TempFileResult { path: string; document: LspDocument; cleanup: () => void; } function createFakeLspDocument(document: TextDocumentItem) { // const doc = TextDocumentItem.create(document.uri, 'fish', 0, document.getText()); const workspace = workspaceManager.findContainingWorkspace(document.uri); if (!workspace) { workspaceManager.add(Workspace.syncCreateFromUri(document.uri)!); } else { workspace.add(document.uri); } return new LspDocument(document); } /** * Create a temporary fish file for testing * * @param content The fish script content to write * @returns Object containing the file path, TextDocument, and cleanup function */ export function createTempFishFile(content: string): TempFileResult { // Create unique filename in temp directory const filename = `test-${Date.now()}-${Math.random().toString(36).slice(2)}.fish`; const filepath = join(tmpdir(), filename); // Write content to file writeFileSync(filepath, content, 'utf8'); // Create TextDocument const document = TextDocumentItem.create( `file://${filepath}`, 'fish', 1, content, ); const lspDocument = createFakeLspDocument(document); // Cleanup function const cleanup = () => { try { unlinkSync(filepath); } catch (err) { console.error(`Failed to cleanup temp file ${filepath}:`, err); } }; return { path: filepath, document: lspDocument, cleanup, }; } /** * Helper to run a test with a temporary fish file * * @param content Fish script content * @param testFn Function that receives the temp file info and runs test assertions */ export async function withTempFishFile( content: string, testFn: (result: TempFileResult) => Promise, ): Promise { const tempFile = createTempFishFile(content); try { await testFn(tempFile); } finally { tempFile.cleanup(); } } ================================================ FILE: tests/test-comprehensive-utility.test.ts ================================================ import { LspDocument } from '../src/document'; import { TestWorkspace, TestFile, Query, DefaultTestWorkspaces } from './test-workspace-utils'; describe('Comprehensive Test Workspace Utility Tests', () => { describe('Functionality verification', () => { const snapshotWorkspace = TestWorkspace.create({ name: 'snapshot_test' }) .addFiles( TestFile.function('test_func', 'function test_func\n echo "test"\nend'), TestFile.completion('test_func', 'complete -c test_func -l help'), ).initialize(); const singleFile = TestWorkspace.create({ name: 'my_single_file', forceAllDefaultWorkspaceFolders: true, }, ).addDocument( LspDocument.create('functions/my_func.fish', 'fish', 1, 'function my_func\nend'), ).initialize(); const multiFileWorkspace = TestWorkspace.create({ name: 'multi_file' }) .addFile(TestFile.function('another_func', 'function another_func\nend')).initialize(); const completion = TestWorkspace.create().addFile( TestFile.completion('mycommand', 'complete -c mycommand -l help'), // // { path: 'completions/mycommand.fish', text: 'complete -c mycommand -l help' }, ).initialize(); // multiFileWorkspace.setupWithFocus(); it('should pass basic API tests showing all features work', async () => { // Test 1: Snapshots work const snapshotPath = snapshotWorkspace.writeSnapshot(); expect(snapshotPath).toContain('.snapshot'); const restoredWorkspace = TestWorkspace.fromSnapshot(snapshotPath); expect(restoredWorkspace.name).toContain('snapshot_test'); expect((restoredWorkspace as any)._files).toHaveLength(2); // Test 2: Single file utility works // singleFile.workspace.setup(); // This should work since we specified the filename expect(singleFile.document!.uri).toBeDefined(); expect(singleFile.document!.getText()).toContain('function my_func'); expect(singleFile.workspace!.getUris()).toHaveLength(1); // Test 3: Query system works const func = singleFile.focus().find(Query.functions())!; // console.log(func.getRelativeFilenameToWorkspace().toString()); // expect(func).toHaveLength(1); expect(func!.getText()).toContain('function my_func'); // Test 4: Unified interface works const result = multiFileWorkspace.asResult(); expect(result.documents).toHaveLength(1); expect(result.documents[0]!.getText()).toContain('function another_func'); // Test 5: Different file types work expect(completion.document!.getText()).toContain('complete -c mycommand'); const completions = completion.getDocuments(Query.completions()); expect(completions).toHaveLength(1); }); // Test error cases it('demonstrates error handling and edge cases', async () => { const singleFile2 = TestWorkspace.createSingleFileReady('function test\nend').workspace.initialize(); // Should throw error if trying to access document before initialization // expect(() => singleFile.document).toThrow('Make sure to call workspace.initialize() first'); // Should work after initialization await new Promise(resolve => setTimeout(resolve, 200)); // Now document access should work expect(singleFile2.focusedDocument).toBeDefined(); }); const approaches = [ TestWorkspace.createSingle('function test1\nend').initialize(), TestWorkspace.create().addFile(TestFile.function('test2', 'function test2\nend')).initialize(), ]; it('shows improved consistency and type safety', async () => { // Both should provide the same API surface for (const approach of approaches) { const hasGetDocument = typeof approach.getDocument === 'function' || typeof approach.getDocument === 'function'; const hasGetDocuments = typeof approach.getDocuments === 'function' || typeof approach.getDocuments === 'function'; const hasDocuments = Array.isArray(approach.documents) || Array.isArray(approach.documents); expect(hasGetDocument).toBe(true); expect(hasGetDocuments).toBe(true); expect(hasDocuments).toBe(true); } }); }); describe('Recommendations implemented', () => { it('provides comprehensive testing coverage', () => { // This test itself demonstrates comprehensive testing expect(true).toBe(true); }); const workspace = TestWorkspace.create({ name: 'snapshot_comprehensive_test' }) .addFile(TestFile.function('snapshot_func', 'function snapshot_func\nend')) .initialize() ; it('ensures snapshots work correctly', async () => { // workspace.setup(); // workspace.initialize(); await new Promise(resolve => setTimeout(resolve, 200)); const snapshotPath = workspace.writeSnapshot(); expect(snapshotPath).toContain('snapshot_comprehensive_test.snapshot'); // Verify snapshot content const fs = require('fs'); const snapshotContent = fs.readFileSync(snapshotPath, 'utf8'); const snapshot = JSON.parse(snapshotContent); expect(snapshot.name).toBe('snapshot_comprehensive_test'); expect(snapshot.files).toHaveLength(1); expect(snapshot.files[0].relativePath).toBe('functions/snapshot_func.fish'); }); describe('single vs multi 1', () => { const single = TestWorkspace.createSingle({ path: 'functions/test.fish', text: 'function test\nend' }).focus().initialize(); const multi = TestWorkspace.create().addFile(TestFile.function('test', 'function test\nend')).focus().initialize(); it('provides unified return types for consistent usage', async () => { // Both implement the same interface pattern expect(single.focusedDocument?.getRelativeFilenameToWorkspace()).toEqual(multi.focusedDocument?.getRelativeFilenameToWorkspace()); }); }); it('demonstrates improved API consistency and type safety', () => { // TypeScript compilation success indicates type safety // Runtime API consistency demonstrated in other tests expect(true).toBe(true); // Placeholder for type safety verification }); describe('compare', () => { const simpleWorkspace = TestWorkspace.create().addFile(TestFile.function('test', 'function test\nend')).initialize().focus(); // Edge case: multiple file types const complexWorkspace = TestWorkspace.create({ autoAnalyze: true, }).addFiles( TestFile.function('func', 'function func\nend'), TestFile.completion('func', 'complete -c func'), TestFile.config('set -g var value'), TestFile.confd('init', 'function init\nend'), TestFile.script('script', 'echo "script"'), ).initialize(); it('includes basic error handling and edge cases', () => { // // Error case: non-existent file // await new Promise(resolve => setTimeout(resolve, 200)); const nonExistentDoc = simpleWorkspace.getDocument('nonexistent.fish'); expect(nonExistentDoc).toBeUndefined(); const focused = simpleWorkspace.focusedDocument; expect(focused).toBeDefined(); // Error case: empty query results const emptyResults = simpleWorkspace.getDocuments(Query.completions()); // No completions in this workspace expect(emptyResults).toHaveLength(0); for (const doc of complexWorkspace.documents) { console.log(doc.getRelativeFilenameToWorkspace().toString()); } expect(complexWorkspace.documents).toHaveLength(5); expect(complexWorkspace.getDocuments(Query.functions())).toHaveLength(1); expect(complexWorkspace.getDocuments(Query.completions())).toHaveLength(1); expect(complexWorkspace.getDocuments(Query.config())).toHaveLength(1); expect(complexWorkspace.getDocuments(Query.confd())).toHaveLength(1); expect(complexWorkspace.getDocuments(Query.scripts())).toHaveLength(1); }); }); }); }); ================================================ FILE: tests/test-setup.test.ts ================================================ import fs from 'fs'; import path from 'path'; import { workspaceManager } from '../src/utils/workspace-manager'; import { focusedWorkspace, TestFile, TestWorkspace } from './test-workspace-utils'; import { SyncFileHelper } from '../src/utils/file-operations'; describe('Test Workspace Setup (`TestWorkspace.create()` usage)', () => { describe('t1', () => { TestWorkspace.create({ name: 'test-setup', autoAnalyze: true, autoFocusWorkspace: true, }).addFiles( TestFile.config('fish_add_path --path /usr/local/bin'), TestFile.confd('paths', ` fish_add_path --path /usr/bin fish_add_path --path ~/.local/bin fish_add_path --path /bin fish_add_path --path /usr/bin `), ).setup(); it('should have a valid workspace', () => { const ws = workspaceManager.current!; expect(ws?.name).toBe('test-setup'); expect(ws?.needsAnalysis()).toBe(false); expect(ws?.uris.indexedCount).toBeGreaterThan(0); expect(ws?.uris.indexedCount).toBe(2); console.log(`Workspace ${ws.name} has ${ws.uris.indexedCount} indexed files.`); console.log(ws.toTreeString()); }); it('auto focus workspace', () => { expect(focusedWorkspace!.name).toBe('test-setup'); expect(focusedWorkspace!.uris.indexedCount).toBe(2); }); }); describe('t2', () => { TestWorkspace.create({ name: 'test-setup-2', autoAnalyze: true, forceAllDefaultWorkspaceFolders: true, addEnclosingFishFolder: true, autoFocusWorkspace: true, }).addFiles( TestFile.config('fish_add_path --path /usr/local/bin'), TestFile.function('ls', `function ls echo "Listing files in current directory" command exa `), TestFile.completion('ls', ` complete -c ls -n "__fish_seen_subcommand_from ls" -f -a "(\ls)" `), ).setup(); it('should have a valid workspace (w/ `fish` enclosing wrapper)', () => { const ws = focusedWorkspace!; console.log({ name: ws.name, uri: ws.uri, uriCount: ws.uris.all.length, needsAnalysis: ws.needsAnalysis(), indexedCount: ws.uris.indexedCount, path: ws.path, docs: ws.allDocuments().map(doc => doc.getRelativeFilenameToWorkspace()), }); expect(ws?.name).toContain('test-setup-2'); expect(ws?.needsAnalysis()).toBe(false); expect(ws?.uris.indexedCount).toBeGreaterThan(0); expect(ws?.uris.indexedCount).toBe(3); console.log(`Workspace ${ws.name} has ${ws.uris.indexedCount} indexed files.`); console.log(ws.toTreeString()); }); it('show tree sitter parse tree', () => { const ws = focusedWorkspace!; // expect(ws).toBeDefined(); ws.showAllTreeSitterParseTrees(); }); }); describe('check cleaned up success', () => { it('should have no workspaces left', () => { const excludeTestWorkspaces = ['test-setup', 'test-setup-2']; const workspacesPath = path.resolve('./tests/workspaces/'); const folders = fs.readdirSync(workspacesPath) .filter(f => !!f.trim()) .map(f => path.join(workspacesPath, f)) .filter(f => SyncFileHelper.isDirectory(f)) .map(f => f.split(path.sep).slice(-1)[0] || f); const badFolders: string[] = folders.filter(f => excludeTestWorkspaces.includes(f)); expect(badFolders.length).toBe(0); }); }); }); ================================================ FILE: tests/test-snapshot-functionality.test.ts ================================================ import { TestWorkspace, TestFile } from './test-workspace-utils'; import * as fs from 'fs'; import * as path from 'path'; describe('Snapshot Functionality Tests', () => { let workspace: TestWorkspace; let snapshotPath: string; beforeAll(() => { workspace = TestWorkspace.create({ name: 'snapshot_test' }) .addFiles( TestFile.function('test_func', 'function test_func\n echo "test"\nend'), TestFile.completion('test_func', 'complete -c test_func -l help'), TestFile.config('set -g fish_greeting "Test config"'), ); workspace.initialize(); }); it('should create a snapshot file', async () => { snapshotPath = workspace.writeSnapshot(); expect(fs.existsSync(snapshotPath)).toBe(true); expect(snapshotPath).toContain('.snapshot'); expect(snapshotPath).toContain('snapshot_test'); }); it('should contain valid JSON snapshot data', async () => { const snapshotContent = fs.readFileSync(snapshotPath, 'utf8'); const snapshot = JSON.parse(snapshotContent); expect(snapshot.name).toBe('snapshot_test'); expect(snapshot.files).toHaveLength(3); expect(snapshot.timestamp).toBeGreaterThan(0); // Check file structure const functionFile = snapshot.files.find((f: any) => f.relativePath === 'functions/test_func.fish'); expect(functionFile).toBeDefined(); expect(functionFile.content).toContain('function test_func'); }); it('should restore workspace file specs from snapshot', async () => { const restoredWorkspace = TestWorkspace.fromSnapshot(snapshotPath); expect(restoredWorkspace.name).toBe('snapshot_test'); // Check that files were added correctly (before initialization) expect((restoredWorkspace as any)._files).toHaveLength(3); // Check file content is preserved const files = (restoredWorkspace as any)._files; const funcFile = files.find((f: any) => f.relativePath === 'functions/test_func.fish'); expect(funcFile.content).toContain('function test_func'); }); it('should create snapshot with custom path', async () => { const customPath = path.join(__dirname, 'custom-snapshot.json'); const customSnapshotPath = workspace.writeSnapshot(customPath); expect(customSnapshotPath).toBe(customPath); expect(fs.existsSync(customPath)).toBe(true); // Cleanup fs.unlinkSync(customPath); }); afterAll(() => { // Cleanup snapshot if (fs.existsSync(snapshotPath)) { fs.unlinkSync(snapshotPath); } }); }); ================================================ FILE: tests/test-workspace-utils.ts ================================================ /** * Test Workspace Utilities for Fish Language Server * * This utility provides a comprehensive framework for creating and managing * temporary fish shell workspaces in tests. It ensures that test fish files * behave exactly like production usage by integrating with the same analysis * pipeline used by the language server. * * @example Basic Usage * ```typescript * import { TestWorkspace, TestFile, Query } from './test-workspace-utils'; * * describe('My Test', () => { * const workspace = TestWorkspace.create() * .addFiles( * TestFile.function('greet', 'function greet\n echo "Hello, $argv[1]!"\nend'), * TestFile.completion('greet', 'complete -c greet -l help') * ).initialize(); * * it('should find documents', () => { * const doc = workspace.getDocument('greet.fish'); * expect(doc).toBeDefined(); * }); * * it('should support queries', () => { * const functions = workspace.getDocuments(Query.functions()); * expect(functions).toHaveLength(1); * }); * }); * ``` * * @example Advanced Querying * ```typescript * // Get specific file types * workspace.getDocuments(Query.functions().withName('foo')); * workspace.getDocuments(Query.completions()); * workspace.getDocuments(Query.autoloaded()); * * // Complex queries * workspace.getDocuments( * Query.functions().withName('foo'), * Query.completions().withName('foo') * ); * ``` * * @example Predefined Workspaces * ```typescript * const workspace = DefaultTestWorkspaces.basicFunctions(); * workspace.initialize(); * ``` */ import * as fs from 'fs'; import * as path from 'path'; import { randomBytes } from 'crypto'; import { LspDocument, documents } from '../src/document'; import { Workspace } from '../src/utils/workspace'; import { workspaceManager } from '../src/utils/workspace-manager'; import { Analyzer, analyzer } from '../src/analyze'; import { pathToUri, uriToPath } from '../src/utils/translation'; import { logger, now } from '../src/logger'; import { SyncFileHelper } from '../src/utils/file-operations'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { execFileSync, execSync } from 'child_process'; import fastGlob from 'fast-glob'; import { Command } from 'commander'; import { testOpenDocument, testCloseDocument, testClearDocuments, testChangeDocument, testGetDocumentCount } from './document-test-helpers'; /** * Query builder for advanced document selection */ export class Query { private _filters: ((doc: LspDocument) => boolean)[] = []; private _returnFirst = false; private constructor() { } /** * Creates a new query */ static create(): Query { return new Query(); } /** * Filters for function files in functions/ directory */ static functions(): Query { return new Query().functions(); } /** * Filters for completion files in completions/ directory */ static completions(): Query { return new Query().completions(); } /** * Filters for config.fish files */ static config(): Query { return new Query().config(); } /** * Filters for conf.d files */ static confd(): Query { return new Query().confd(); } /** * Filters for script files (non-autoloaded) */ static scripts(): Query { return new Query().scripts(); } /** * Filters for any autoloaded files */ static autoloaded(): Query { return new Query().autoloaded(); } /** * Filters by file name */ static withName(name: string): Query { return new Query().withName(name); } /** * Filters by path pattern */ static withPath(...patterns: string[]): Query { return new Query().withPath(...patterns); } /** * Returns only the first match */ static firstMatch(): Query { return new Query().firstMatch(); } // Instance methods for chaining /** * Filters for function files in functions/ directory */ functions(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return docPath.includes('/functions/') && docPath.endsWith('.fish'); }); return this; } /** * Filters for completion files in completions/ directory */ completions(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return docPath.includes('/completions/') && docPath.endsWith('.fish'); }); return this; } /** * Filters for config.fish files */ config(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return path.basename(docPath) === 'config.fish'; }); return this; } /** * Filters for conf.d files */ confd(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return docPath.includes('/conf.d/') && docPath.endsWith('.fish'); }); return this; } /** * Filters for script files (non-autoloaded) */ scripts(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return ( docPath.includes('/scripts/') || !docPath.includes('/functions/') && !docPath.includes('/completions/') && !docPath.includes('/conf.d/') && path.basename(docPath) !== 'config.fish' ) && docPath.endsWith('.fish'); }); return this; } /** * Filters for any autoloaded files */ autoloaded(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return ( docPath.includes('/functions/') || docPath.includes('/completions/') || docPath.includes('/conf.d/') || path.basename(docPath) === 'config.fish' ) && docPath.endsWith('.fish'); }); return this; } /** * Filters by file name (with or without .fish extension) */ withName(name: string): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); const basename = path.basename(docPath, '.fish'); const basenameWithExt = path.basename(docPath); return basename === name || basenameWithExt === name; }); return this; } /** * Filters by path patterns */ withPath(...patterns: string[]): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return patterns.some(pattern => docPath.includes(pattern)); }); return this; } /** * Returns only the first match */ firstMatch(): Query { this._returnFirst = true; return this; } /** * Executes the query against a list of documents */ execute(documents: LspDocument[]): LspDocument[] { let result = documents; // Apply all filters for (const filter of this._filters) { result = result.filter(filter); } // Return first match if requested if (this._returnFirst) { return result.slice(0, 1); } return result; } } /** * Represents a test file with its content and path information */ export interface TestFileSpec { /** Relative path within the fish workspace (e.g., 'functions/foo.fish', 'config.fish') */ relativePath: string; /** File content as string or array of lines */ content: string | string[]; } export namespace TestFileSpec { export function is(item: any): item is TestFileSpec { return item && typeof item.relativePath === 'string' && (typeof item.content === 'string' || Array.isArray(item.content)); } } export interface TestFileSpecLegacy { path: string; text: string | string[]; } export namespace TestFileSpecLegacy { export function is(item: any): item is TestFileSpecLegacy { return item && typeof item.path === 'string' && typeof item.text === 'string' && (typeof item.text === 'string' || Array.isArray(item.text)); } export function toNewFormat(item: TestFileSpecLegacy): TestFileSpec { if (Array.isArray(item.text)) { return { relativePath: item.path, content: item.text.join('\n'), }; } return { relativePath: item.path, content: item.text, }; } // export function } export namespace TestFileSpecInput { export function is(item: any): item is TestFileSpecInput { return TestFileSpecLegacy.is(item) || item && typeof item.relativePath === 'string' && (typeof item.content === 'string' || Array.isArray(item.content)); } } export type TestFileSpecInput = TestFileSpec | TestFileSpecLegacy; /** * Configuration options for test workspace creation */ export interface TestWorkspaceConfig { /** Custom workspace name. If not provided, a unique name will be generated */ name?: string; /** Base directory for test workspaces. Defaults to 'tests/workspaces' */ baseDir?: string; /** Whether to enable debug logging for workspace operations */ debug?: boolean; /** Whether to automatically analyze documents after creation */ autoAnalyze?: boolean; /** Whether to prevent cleanup on inspect() calls */ preserveOnInspect?: boolean; /** Whether to allow empty workspace folders (default: false) */ forceAllDefaultWorkspaceFolders?: boolean; /** always backup snapshot after cleanup */ writeSnapshotOnceSetup?: boolean; /** automatically focus the created workspace */ autoFocusWorkspace?: boolean; /** * prefix created workspace paths with second outermost `fish` folder * (e.g., `tests/workspaces//fish/..`) */ addEnclosingFishFolder?: boolean; } export interface ReadWorkspaceConfig { folderPath: string; debug?: boolean; includeEnclosingFishFolder?: boolean; } export namespace ReadWorkspaceConfig { export function is(item: any): item is ReadWorkspaceConfig { return item && typeof item.folderPath === 'string' && (item.debug === undefined || typeof item.debug === 'boolean') && (item.includeEnclosingFishFolder === undefined || typeof item.includeEnclosingFishFolder === 'boolean'); } export function fromInput(input: string | ReadWorkspaceConfig): ReadWorkspaceConfig { if (typeof input === 'string') { return { folderPath: input, debug: false, includeEnclosingFishFolder: false }; } return { folderPath: input.folderPath, debug: input.debug ?? false, includeEnclosingFishFolder: input.includeEnclosingFishFolder ?? false, }; } } /** * Snapshot data for recreating workspaces */ export interface WorkspaceSnapshot { name: string; files: TestFileSpec[]; timestamp: number; } /** * Helper class for creating different types of fish files */ export class TestFile { private constructor( public relativePath: string, public content: string | string[], ) { } /** * Creates a function file in the functions/ directory */ static function(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`functions/${filename}`, content); } /** * Creates a completion file in the completions/ directory */ static completion(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`completions/${filename}`, content); } /** * Creates a config.fish file */ static config(content: string | string[]) { return new TestFile('config.fish', content); } /** * Creates a conf.d file */ static confd(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`conf.d/${filename}`, content); } /** * Creates a script file (non-autoloaded) */ static script(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`${filename}`, content); } /** * Creates a custom file at any relative path */ static custom(relativePath: string, content: string | string[]) { return new TestFile(relativePath, content); } withShebang(shebang: string = '#!/usr/bin/env fish'): TestFile { // Add shebang to the content if it's a string const contentWithShebang = Array.isArray(this.content) ? [shebang, ...this.content] : `${shebang}\n${this.content}`; return new TestFile(this.relativePath, contentWithShebang); } static fromInput(relativePath: string, content: string | string[]): TestFile { if (relativePath === 'config.fish') { return TestFile.config(content); } switch (path.dirname(relativePath)) { case 'functions': return TestFile.function(path.basename(relativePath), content); case 'completions': return TestFile.completion(path.basename(relativePath), content); case 'conf.d': return TestFile.confd(path.basename(relativePath), content); case '.': if (path.basename(relativePath) === 'config.fish') { return TestFile.config(content); } return TestFile.script(path.basename(relativePath), content); } return new TestFile(relativePath, content); } } export let focusedWorkspace: Workspace | null = null; /** * Main test workspace utility class */ export class TestWorkspace { private readonly _name: string; private readonly _basePath: string; private readonly _workspacePath: string; private readonly _config: Required; private _files: TestFileSpec[] = []; private _documents: LspDocument[] = []; private _workspace: Workspace | null = null; private _isInitialized = false; private _isInspecting = false; // private _beforeAllSetup = false; // private _afterAllCleanup = false; private _focusedDocumentPath: string | null = null; private constructor(config: TestWorkspaceConfig = {}) { this._config = { name: config.name ?? this._generateUniqueName() + performance.now().toString().replace('.', ''), baseDir: config.baseDir || 'tests/workspaces', debug: config.debug ?? false, autoAnalyze: config.autoAnalyze ?? true, preserveOnInspect: config.preserveOnInspect ?? false, // Allow empty workspace folders by default forceAllDefaultWorkspaceFolders: config.forceAllDefaultWorkspaceFolders ?? false, writeSnapshotOnceSetup: config.writeSnapshotOnceSetup ?? false, autoFocusWorkspace: config.autoFocusWorkspace ?? true, addEnclosingFishFolder: config.addEnclosingFishFolder ?? false, }; this._name = this._config.name; this._basePath = path.resolve(this._config.baseDir); this._workspacePath = path.join(this._basePath, this._name); if (SyncFileHelper.exists(this._workspacePath)) { this._name = this.name + this._generateUniqueName() + new Date().getMilliseconds().toString() + randomBytes(2).toString('hex'); this._basePath = path.resolve(this._config.baseDir); this._workspacePath = path.join(this._basePath, this._name); } if (this._config.addEnclosingFishFolder) { this._workspacePath = path.join(this._workspacePath, 'fish'); } if (this._config.debug) { logger.log(`TestWorkspace created: ${this._name} at ${this._workspacePath}`); } } static createBaseWorkspace() { return new TestWorkspace(); } reset() { if (this._isInitialized) { for (const doc of this._documents) { const filePath = uriToPath(doc.uri); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); fs.rmSync(filePath, { recursive: true, force: true }); } } for (const dir of ['functions', 'completions', 'conf.d']) { const dirPath = path.join(this._workspacePath, dir); if (fs.existsSync(dirPath)) { fs.rmdirSync(dirPath, { recursive: true }); } } } if (!fs.existsSync(this._workspacePath)) { fs.mkdirSync(this._workspacePath, { recursive: true }); } this._files = []; this._documents = []; this._workspace = null; this._isInitialized = false; this._isInspecting = false; this._focusedDocumentPath = null; return this; } /** * Generates a unique workspace from an existing test workspace directory * * `TestWorkspace` can be created using one of the following methods: * - TestWorkspace.read(...) * - TestWorkspace.create(...) * - TestWorkspace.createSingle(...) * * @example * ```typescript * import { TestWorkspace } from './test-workspace-utils'; * * describe('read workspace 1 from directory `workspace_1/fish`', () => { * * const ws = TestWorkspace.read('workspace_1/fish') * .initialize() * * it('should read files from the specified directory', () => { * const docs = ws.documents * expect(docs.length).toBeGreaterThan(2); * }); * * }); * ``` * * Normally, you would need to chain `.setup()`/`.initialize()` after creation to * set up the workspace for testing. * * @param input Path to the workspace directory or configuration object * @returns A new TestWorkspace instance populated with files from the specified directory */ static read(input: ReadWorkspaceConfig | string): TestWorkspace { const config: ReadWorkspaceConfig = ReadWorkspaceConfig.fromInput(input); const absPath = path.isAbsolute(config.folderPath) ? config.folderPath : fs.existsSync(path.join('tests', 'workspaces', config.folderPath)) ? path.resolve(path.join('tests', 'workspaces', config.folderPath)) : path.resolve(config.folderPath); let basePath = absPath; if (fs.existsSync(path.join(absPath, 'fish')) && fs.statSync(path.join(absPath, 'fish')).isDirectory()) { basePath = path.join(basePath, 'fish'); } const workspace = new TestWorkspace({ debug: config.debug, addEnclosingFishFolder: config.includeEnclosingFishFolder }); fastGlob.sync(['**/*.fish'], { cwd: absPath, absolute: true, onlyFiles: true, }).forEach(filePath => { let relPath = path.relative(absPath, filePath); if (basePath.endsWith('fish') && relPath.startsWith('fish/')) { relPath = relPath.substring(5); } const content = fs.readFileSync(filePath, 'utf8'); workspace._files.push(TestFile.fromInput(relPath, content)); if (config.debug) console.log(`Loaded file: ${relPath}`); }); return workspace; } /** * Creates a new test workspace instance * * `TestWorkspace` can be created using one of the following methods: * - TestWorkspace.read(...) * - TestWorkspace.create(...) * - TestWorkspace.createSingle(...) * * @example * ```typescript * describe('My Test', () => { * const workspace = TestWorkspace.create({name: 'my_test_workspace'}) * .addFiles(TestFile.function('greet', 'function greet\n echo "Hello, $argv[1]!"\nend')) * .initialize(); * * it('should work', () => { * const doc = workspace.focusedDocument; * expect(doc?.getText()).toContain('function greet'); * }); * }); * ``` * * Normally, you would need to chain `.setup()`/`.initialize()` after creation to * set up the workspace for testing. * * @param config Optional configuration for the workspace * @returns A new TestWorkspace instance */ static create(config?: TestWorkspaceConfig): TestWorkspace { return new TestWorkspace(config); } /** * Creates a single file workspace with unified API - convenience method * * @example * ```typescript * describe('My Test', () => { * const workspace = TestWorkspace.createSingle('function greet\n echo "hello"\nend') * .setup(); * * it('should work', () => { * const doc = workspace.focusedDocument; * expect(doc?.getText()).toContain('function greet'); * }); * }); * ``` */ static createSingle( content: string | string[] | TestFileSpecInput, type: 'function' | 'completion' | 'config' | 'confd' | 'script' | 'custom' = 'function', filename?: string, ): TestWorkspace { const name = filename || TestWorkspace._generateRandomName(); const workspace = TestWorkspace.create({ name: `single_${name}` }); // Create the appropriate file based on type let testFile: TestFile; if (TestFileSpecInput.is(content) && typeof content !== 'string' && !Array.isArray(content)) { if (TestFileSpecLegacy.is(content)) { const input = TestFileSpecLegacy.toNewFormat(content); testFile = TestFile.fromInput(input.relativePath, input.content); } else { testFile = TestFile.fromInput(content.relativePath, content.content); } } else { switch (type) { case 'function': testFile = TestFile.function(name, content); break; case 'completion': testFile = TestFile.completion(name, content); break; case 'config': testFile = TestFile.config(content); break; case 'confd': testFile = TestFile.confd(name, content); break; case 'script': testFile = TestFile.script(name, content); break; default: testFile = TestFile.custom(name, content); break; } } workspace.addFile(testFile); workspace._focusedDocumentPath = testFile.relativePath; return workspace; } static createSingleFileReady( content: string | string[] | TestFileSpecInput, ): { document: LspDocument; workspace: TestWorkspace; } { const workspace = new TestWorkspace({ name: `single_${TestWorkspace._generateRandomName()}` }); if (typeof content === 'string' || Array.isArray(content)) { workspace.addFile( TestFile.confd('single_file.fish', content), ); } else if (TestFileSpecLegacy.is(content)) { workspace.addFile(TestFileSpecLegacy.toNewFormat(content)); } else { workspace.addFile(content); } // const workspace = TestWorkspace.createSingle(content) workspace.initialize(); return { document: workspace.documents.at(0)!, workspace, }; } /** * Creates a test workspace from a snapshot */ static fromSnapshot(snapshotPath: string): TestWorkspace { const snapshotContent = fs.readFileSync(snapshotPath, 'utf8'); const snapshot: WorkspaceSnapshot = JSON.parse(snapshotContent); const workspace = new TestWorkspace({ name: snapshot.name }); workspace.addFiles(...snapshot.files); return workspace; } /** * Adds files to the workspace */ addFiles(...files: TestFileSpecInput[]): TestWorkspace { for (const file of files) { if (TestFileSpecLegacy.is(file)) { if (this._files.some(f => f.relativePath === file.path)) { continue; } this._files.push(TestFileSpecLegacy.toNewFormat(file)); } else { if (this._files.some(f => f.relativePath === file.relativePath)) { continue; } this._files.push(file); } } return this; } /** * Adds a single file to the workspace */ addFile(file: TestFileSpecInput): TestWorkspace { const newFilePath = TestFileSpecLegacy.is(file) ? file.path : file.relativePath; if (this._files.some(f => f.relativePath === newFilePath)) { return this; } if (TestFileSpecLegacy.is(file)) { this._files.push(TestFileSpecLegacy.toNewFormat(file)); } else { this._files.push(file); } return this; } /** * Inherits files from an existing autoloaded workspace directory */ inheritFilesFromExistingAutoloadedWorkspace(sourcePath: string): TestWorkspace { if (sourcePath.startsWith('$')) { const stdout = execFileSync('fish', ['-c', `echo ${sourcePath}`]).toString().trim(); if (stdout !== sourcePath && !fs.existsSync(sourcePath) && fs.existsSync(stdout)) { sourcePath = stdout; } if (!fs.existsSync(sourcePath)) { logger.error(`Source path does not exist: ${sourcePath}`); return this; } } if (SyncFileHelper.isExpandable(sourcePath) && !SyncFileHelper.isAbsolutePath(sourcePath)) { sourcePath = SyncFileHelper.expandEnvVars(sourcePath); } if (!fs.existsSync(sourcePath)) { if (this._config.debug) { logger.warning(`Source path does not exist: ${sourcePath}`); } return this; } const fishDirs = ['functions', 'completions', 'conf.d']; const configFile = 'config.fish'; // Copy config.fish if it exists const configPath = path.join(sourcePath, configFile); if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, 'utf8'); this.addFile(TestFile.config(content)); } // Copy files from fish directories for (const dir of fishDirs) { const dirPath = path.join(sourcePath, dir); if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { const files = fs.readdirSync(dirPath).filter(file => file.endsWith('.fish')); for (const file of files) { const filePath = path.join(dirPath, file); const content = fs.readFileSync(filePath, 'utf8'); const relativePath = `${dir}/${file}`; this.addFile(TestFile.custom(relativePath, content)); } } } if (this._config.debug) { logger.log(`Inherited files from: ${sourcePath}`); } return this; } /** * Edits a file in the workspace to simulate live editing */ editFile(searchPath: string, newContent: string | string[]): void { if (!this._isInitialized) { throw new Error('Workspace must be initialized before editing files'); } const doc = this.getDocument(searchPath); if (!doc) { throw new Error(`Document not found: ${searchPath}`); } const content = Array.isArray(newContent) ? newContent.join('\n') : newContent; const filePath = uriToPath(doc.uri); // Update file on disk fs.writeFileSync(filePath, content, 'utf8'); // Update document in memory and trigger re-analysis documents.get(doc.uri)?.update([{ text: content }]); // Update our local document reference const docIndex = this._documents.findIndex(d => d.uri === doc.uri); if (docIndex !== -1) { const updatedDoc = documents.get(doc.uri) || LspDocument.createFromUri(doc.uri); this._documents[docIndex] = updatedDoc; if (this._config.autoAnalyze) { analyzer.analyze(updatedDoc); } } if (this._config.debug) { logger.log(`Edited file: ${searchPath}`); } } addDocuments(...item: (LspDocument | TestFileSpec)[]): TestWorkspace { for (const it of item) { this.addDocument(it); } return this; } addDocument(item: LspDocument | TestFileSpec): TestWorkspace { if (LspDocument.is(item)) { this._documents.push(item); this._files.push({ relativePath: path.relative(this._workspacePath, uriToPath(item.uri)), content: item.getText(), }); workspaceManager.current?.addPending(item.uri); } else { const fileSpec: TestFileSpec = { relativePath: item.relativePath, content: item.content, }; SyncFileHelper.write(path.join(this._workspacePath, fileSpec.relativePath), Array.isArray(item.content) ? item.content.join('\n') : item.content); const doc = LspDocument.createFromPath(path.join(this._workspacePath, fileSpec.relativePath)); this._files.push(fileSpec); workspaceManager.current?.addPending(doc.uri); } return this; } /** * Sets up the workspace - handles beforeAll() functionality */ // initialize(): TestWorkspace { // if (!this._beforeAllSetup) { // beforeAll(async () => { // await setupProcessEnvExecFile(); // if (!this._config.debug) logger.setSilent(true); // await this._createWorkspaceFiles(); // await this._setupWorkspace(); // this._isInitialized = true; // }); // this._beforeAllSetup = true; // logger.setSilent(false); // } // // if (!this._afterAllCleanup) { // afterAll(async () => { // if (!this._isInspecting || !this._config.preserveOnInspect) { // await this._cleanup(); // } // testWorkspaces = []; // }); // this._afterAllCleanup = true; // } // // beforeEach(async () => { // if (!this._config.debug) logger.setSilent(true); // workspaceManager.clear(); // await setupProcessEnvExecFile(); // await this._resetAnalysisState(); // await this._setupWorkspace(); // workspaceManager.setCurrent(this.getWorkspace()!); // await workspaceManager.analyzePendingDocuments(); // logger.setSilent(false); // }); // // afterEach(async () => { // this._resetAnalysisState(); // workspaceManager.clear(); // await Analyzer.initialize(); // }); // // return this; // } // initialize() { this.setup(); if (this._files.length === 1) { this.focus(); } return this; } get setup() { return () => { beforeAll(async () => { await setupProcessEnvExecFile(); await Analyzer.initialize(); logger.setSilent(); await this._createWorkspaceFiles(); await this._setupWorkspace(); this._isInitialized = true; }); beforeEach(async () => { const wasSilentBefore = logger.isSilent(); logger.setSilent(true); if (!this._isInitialized) { logger.setSilent(true); workspaceManager.clear(); await setupProcessEnvExecFile(); await this._resetAnalysisState(); await this._setupWorkspace(); } workspaceManager.setCurrent(this.getWorkspace()!); await workspaceManager.analyzePendingDocuments(); // this._workspace = workspaceManager.current!; if (!this._config.debug && !wasSilentBefore) { logger.setSilent(false); } if (this._config.autoFocusWorkspace) { focusedWorkspace = this.getWorkspace(); this._workspace = focusedWorkspace; } this._isInitialized = true; }); afterEach(async () => { this._isInitialized = false; }); afterAll(async () => { if (!this._isInspecting && !this._config.preserveOnInspect) { await this._cleanup(); if (this._config.debug) { logger.log(`Cleaned up workspace: ${this._workspacePath}`); } } }); }; } /** * Sets the focused document path for single-file usage */ focus(documentPath?: string | number): TestWorkspace { if (typeof documentPath === 'number') { this._focusedDocumentPath = this._files[documentPath]?.relativePath || null; return this; } if (!documentPath) { if (this._files.length === 1) { this._focusedDocumentPath = this._files[0]!.relativePath; } else { this._focusedDocumentPath = this._files[0] ? this._files[0]!.relativePath : null; } } else { this._focusedDocumentPath = documentPath; } return this; } /** * Setup with automatic focus on the single file (for single-file workspaces) */ get setupWithFocus() { if (this._files.length === 1) { this._focusedDocumentPath = this._files[0]!.relativePath; } return this.setup; } /** * Gets all documents in the workspace */ get documents(): LspDocument[] { return this._documents; } /** * Gets the focused document (for single-file workspaces) */ get focusedDocument(): LspDocument | null { if (!this._focusedDocumentPath) return null; return this.getDocument(this._focusedDocumentPath) || null; } get document(): LspDocument | null { return this.focusedDocument || this.documents[0] || null; } get workspace(): Workspace | null { return this._workspace; } /** * Gets the workspace name */ get name(): string { return this._name; } /** * Gets the workspace path */ get path(): string { return this._workspacePath; } /** * Gets the workspace URI */ get uri(): string { return pathToUri(this._workspacePath); } /** * Gets a document by its relative path or filename */ getDocument(searchPath: string): LspDocument | undefined { return this._documents.find(doc => { const docPath = uriToPath(doc.uri); const relativePath = path.relative(this._workspacePath, docPath); // Try exact match first if (relativePath === searchPath) return true; // Try filename match if (path.basename(docPath) === searchPath) return true; // Try ending match (e.g., 'functions/foo.fish' matches 'foo.fish') if (relativePath.endsWith(searchPath)) return true; return false; }); } /** * Gets documents using advanced query system */ getDocuments(...queries: Query[]): LspDocument[] { if (queries.length === 0) { return [...this._documents]; } // Combine all query results const allResults = new Set(); for (const query of queries) { const results = query.execute(this._documents); results.forEach(doc => allResults.add(doc.uri)); } for (const uri of Array.from(allResults)) { if (allResults.has(uri) && !this._documents.some(doc => doc.uri === uri)) { allResults.delete(uri); } } const finalResults: LspDocument[] = []; for (const uri of Array.from(allResults)) { const found = this._documents.find(doc => { if (doc.uri === uri) { finalResults.push(doc); return true; } }); if (found && !finalResults.map(d => d.uri).includes(found.uri)) { finalResults.push(found); } } return finalResults; } find(...query: (Query | string | number)[]) { if (query.length === 0) { return this.documents.at(0) || null; } if (query.length === 1) { const q = query[0]; if (typeof q === 'string') { return this.documents.find(doc => doc.uri.endsWith(q)) || null; } else if (typeof q === 'number') { return this.documents.at(q) || null; } else { const results = q!.execute(this._documents); return results.at(0) || null; } } if (query.length > 1) { let results = this.getDocuments(); for (const q of query) { if (typeof q === 'string') { results = results.filter(doc => { const docPath = uriToPath(doc.uri); const relativePath = path.relative(this._workspacePath, docPath); return relativePath === q || path.basename(docPath) === q || relativePath.endsWith(q); }); } else if (typeof q === 'number') { results = results.slice(q, q + 1); } else { results = q!.execute(results); } } return results.at(0) || null; } return null; } /** * Gets the analyzed workspace instance */ getWorkspace(): Workspace | null { return this._workspace; } /** * Converts this workspace to a TestWorkspaceResult for unified API */ asResult() { const ws = this.getWorkspace(); const docs = this.documents; const getDoc = (searchPath: string) => this.getDocument(searchPath); const getDocs = (...queries: Query[]) => this.getDocuments(...queries); return { workspace: ws, documents: docs, getDocument: getDoc, getDocuments: getDocs, }; } /** * Prevents cleanup for inspection purposes */ inspect(): TestWorkspace { this._isInspecting = true; return this; } /** * Dumps the file tree structure */ dumpFileTree(): string { if (!fs.existsSync(this._workspacePath)) { return 'Workspace not created yet'; } 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(this._name + '/'); buildTree(this._workspacePath, ''); return tree.join('\n'); } /** * Creates a snapshot of the current workspace */ writeSnapshot(outputPath?: string): string { const timestamp = Date.now(); const snapshotPath = outputPath || path.join(this._basePath, `${this._name}.snapshot`); const snapshot: WorkspaceSnapshot = { name: this._name, files: this._files, timestamp, }; fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2)); return snapshotPath; } // Private methods private _generateUniqueName(): string { const timestamp = Date.now().toString(36); const random = randomBytes(4).toString('hex'); return `test_workspace_${timestamp}_${random}`; } private static _generateRandomName(): string { const timestamp = Date.now().toString(36); const random = randomBytes(3).toString('hex'); return `test_${timestamp}_${random}`; } private async _createWorkspaceFiles(): Promise { // Ensure workspace directory exists if (fs.existsSync(this._workspacePath)) { // Handle existing directory by adding suffix let counter = 1; let newName = `${this._name}_${counter}`; let newPath = path.join(this._basePath, newName); while (fs.existsSync(newPath)) { counter++; newName = `${this._name}_${counter}`; newPath = path.join(this._basePath, newName); } (this as any)._name = newName; (this as any)._workspacePath = newPath; if (this._config.debug) { logger.log(`Workspace directory exists, using: ${newName}`); } } fs.mkdirSync(this._workspacePath, { recursive: true }); // Create fish directory structure if (this._config.forceAllDefaultWorkspaceFolders) { const fishDirs = ['functions', 'completions', 'conf.d']; fishDirs.forEach(dir => { const dirPath = path.join(this._workspacePath, dir); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } }); } // Write all files for (const file of this._files) { const filePath = path.join(this._workspacePath, file.relativePath); // Write file content const content = Array.isArray(file.content) ? file.content.join('\n') : file.content; SyncFileHelper.writeRecursive(filePath, content, 'utf8'); if (this._config.debug) { logger.log(`Created file: ${file.relativePath}`); } } if (this._config.writeSnapshotOnceSetup) { this.writeSnapshot(); } } private async _setupWorkspace(): Promise { // Initialize analyzer if not already done if (!analyzer || !analyzer.started) { await Analyzer.initialize(); } // const curr = documents.all() // workspaceManager.clear(); // Create workspace instance this._workspace = Workspace.syncCreateFromUri(this.uri); if (!this._workspace) { throw new Error(`Failed to create workspace from ${this.uri}`); } // this._workspace.allUris.clear(); // this._workspace.addPending(...Array.from(new Set(this._files.map(f => pathToUri(path.join(this._workspacePath, f.relativePath)))))); // this._workspace!.name = this._name; // Add workspace to manager // workspaceManager.clear() // Create LspDocument instances for all files for (const file of Array.from(new Set(this._files))) { const filePath = path.join(this._workspacePath, file.relativePath); if (!fs.existsSync(filePath)) { SyncFileHelper.writeRecursive(filePath, Array.isArray(file.content) ? file.content.join('\n') : file.content, 'utf8'); } const uri = pathToUri(filePath); const doc = LspDocument.createFromUri(uri); if (this._documents.some(d => d.uri === doc.uri)) { continue; } this._documents.push(doc); this._workspace.add(uri); if (this._config.autoAnalyze) { workspaceManager.handleOpenDocument(doc); analyzer.analyze(doc); testOpenDocument(doc); } } await workspaceManager.analyzePendingDocuments(); workspaceManager.setCurrent(this._workspace); if (this._config.debug) { logger.log(`Workspace setup complete: ${this._documents.length} documents created`); } } async analyzeAllFiles() { logger.setSilent(); if (!analyzer || !analyzer.started) { await Analyzer.initialize(); } // Create workspace instance this._workspace = Workspace.syncCreateFromUri(this.uri); if (!this._workspace) { throw new Error(`Failed to create workspace from ${this.uri}`); } this._workspace!.name = this._name; // Add workspace to manager workspaceManager.add(this._workspace); workspaceManager.setCurrent(this._workspace); // Create LspDocument instances for all files for (const file of this._files) { const filePath = path.join(this._workspacePath, file.relativePath); SyncFileHelper.writeRecursive(filePath, Array.isArray(file.content) ? file.content.join('\n') : file.content, 'utf8'); const uri = pathToUri(filePath); const doc = LspDocument.createFromUri(uri); this._documents.push(doc); this._workspace.add(uri); if (this._config.autoAnalyze) { testOpenDocument(doc); workspaceManager.handleOpenDocument(doc); analyzer.analyze(doc); // workspaceManager.current?.addUri(doc.uri); } } await workspaceManager.analyzePendingDocuments(); workspaceManager.setCurrent(this._workspace); logger.setSilent(false); } private async _resetAnalysisState(): Promise { // Clear global documents state but don't remove files from disk testClearDocuments(); // Re-add our documents if needed if (this._config.autoAnalyze) { for (const doc of this._files) { const filePath = path.join(this._workspacePath, doc.relativePath); const uri = pathToUri(filePath); const lspDoc = LspDocument.createFromUri(uri); workspaceManager.handleOpenDocument(lspDoc); analyzer.analyze(lspDoc); testOpenDocument(lspDoc); } } } private async _cleanup(): Promise { try { // Clear documents state testClearDocuments(); // Remove workspace from manager if (this._workspace) { workspaceManager.remove(this._workspace); } // Remove files from disk // For workspaces with addEnclosingFishFolder, we need to remove the parent directory const cleanupPath = this._config.addEnclosingFishFolder ? path.dirname(this._workspacePath) : this._workspacePath; if (fs.existsSync(cleanupPath)) { fs.rmSync(cleanupPath, { recursive: true, force: true }); if (this._config.debug) { logger.log(`Cleaned up workspace: ${cleanupPath}`); } } } catch (error) { if (this._config.debug) { logger.error(`Error during cleanup: ${error}`); } } } } /** * Utility functions for controlling logger behavior during tests */ export class TestLogger { private static _previousLogLevel: any = null; /** * Disables logging output for cleaner test output */ static setSilent(silent: boolean): void { if (silent) { if (TestLogger._previousLogLevel === null) { // Store current log configuration if not already stored TestLogger._previousLogLevel = { // Add any logger state you want to preserve }; } // Disable logging (implementation depends on your logger) // logger.setLevel('silent') or similar } else { // Restore previous logging state if (TestLogger._previousLogLevel !== null) { // Restore logger configuration TestLogger._previousLogLevel = null; } } } /** * Enables debug logging for test workspace operations */ static enableTestWorkspaceLogging(): void { // Enable debug logging specifically for test workspace operations TestLogger.setSilent(false); } } /** * Predefined test workspaces for common testing scenarios */ export class DefaultTestWorkspaces { static emptyWorkspace(): TestWorkspace { return TestWorkspace.create({ name: `empty_workspace_${now().replace(' ', '_')}` }).reset(); } /** * Creates a basic fish function workspace */ static basicFunctions(): TestWorkspace { return TestWorkspace.create({ name: 'basic_functions' }) .addFiles( TestFile.function('greet', ` function greet echo "Hello, $argv[1]!" end`), TestFile.function('add', ` function add math $argv[1] + $argv[2] end`), TestFile.completion('greet', ` complete -c greet -a "(ls)" complete -c greet -l help -d "Show help"`), ); } /** * Creates a workspace with complex function interactions */ static complexFunctions(): TestWorkspace { return TestWorkspace.create({ name: 'complex_functions' }) .addFiles( TestFile.function('main', ` function main set -l result (helper_func $argv) process_result $result end`), TestFile.function('helper_func', ` function helper_func echo "Processing: $argv" end`), TestFile.function('process_result', ` function process_result if test -n "$argv[1]" echo "Result: $argv[1]" else echo "No result" end end`), TestFile.config(` set -g my_global_var "default_value" source (dirname (status --current-filename))/functions/main.fish`), ); } /** * Creates a workspace with configuration and event handlers */ static configAndEvents(): TestWorkspace { return TestWorkspace.create({ name: 'config_and_events' }) .addFiles( TestFile.config(` set -g fish_greeting "Welcome to test workspace!" set -gx PATH $PATH /usr/local/test/bin`), TestFile.confd('setup', ` function setup_test_env --on-event fish_prompt if not set -q test_env_loaded set -g test_env_loaded true echo "Test environment loaded" end end`), TestFile.confd('cleanup', ` function cleanup_test_env --on-event fish_exit echo "Cleaning up test environment" end`), ); } /** * Creates a workspace that simulates a real project structure */ static projectWorkspace(): TestWorkspace { return TestWorkspace.create({ name: 'project_workspace' }) .addFiles( // Main project functions TestFile.function('build', ` function build echo "Building project..." if test -f Makefile make else if test -f package.json npm run build else echo "No build system found" return 1 end end`), TestFile.function('test', ` function test echo "Running tests..." if test -f package.json npm test else if test -f Cargo.toml cargo test else echo "No test framework found" return 1 end end`), TestFile.function('deploy', ` function deploy build if test $status -eq 0 echo "Deploying..." # Deployment logic here else echo "Build failed, cannot deploy" return 1 end end`), // Project completions TestFile.completion('build', ` complete -c build -l verbose -d "Enable verbose output" complete -c build -l clean -d "Clean before building"`), TestFile.completion('deploy', ` complete -c deploy -l staging -d "Deploy to staging" complete -c deploy -l production -d "Deploy to production"`), // Project configuration TestFile.config(` # Project-specific configuration set -gx PROJECT_ROOT (dirname (status --current-filename)) set -gx PROJECT_NAME "fish-test-project" # Add project bin to PATH set -gx PATH $PROJECT_ROOT/bin $PATH`), // Scripts (non-autoloaded) TestFile.script('install', `#!/usr/bin/env fish # Installation script for the project echo "Installing project dependencies..." if test -f package.json npm install else if test -f Cargo.toml cargo build end echo "Project installed successfully!"`), ); } } export function cliModule() { const program = new Command() .name('test-workspace-utils') .description('Utility to create and manage test workspaces for fish-language-server') .version('1.0.0') .option('-n, --name ', 'Name of the workspace to create') .option('-i, --input ', 'Path to the workspace directory to read') .option('--show-tree', 'Show the file tree of the created workspace') .option('--show-tree-sitter-ast', 'Show the Tree-sitter AST of all documents in workspace') .option('--save-snapshot', 'Save a snapshot of the created workspace') .option('--convert-snapshot-to-workspace', 'Convert a snapshot file back to a workspace directory') .option('-h, --help', 'Show help message'); program.parse(); const options = program.opts(); if (options.help) { program.outputHelp(); process.exit(0); } let workspace: TestWorkspace | null = null; let wsPath = ''; const inputIsSnapshot = options.input && options.input.endsWith('.snapshot'); if (options.name) { wsPath = fastGlob.globSync([`${options.name}*.snapshot`, `${options.name}*`], { cwd: path.resolve('./tests/workspaces'), deep: 1 })[0] || ''; } else if (options.input) { wsPath = path.resolve(options.input); } else { console.error('Error: You must specify either a workspace name or an input path.'); program.outputHelp(); process.exit(1); } if (wsPath.endsWith('.snapshot') || inputIsSnapshot) { workspace = TestWorkspace.fromSnapshot(wsPath); workspace.inspect(); if (options.convertSnapshotToWorkspace) { workspace.initialize(); console.log(`Converted snapshot to workspace at: ${workspace.path}`); process.exit(0); } } else if (fs.existsSync(wsPath) && fs.statSync(wsPath).isDirectory()) { workspace = TestWorkspace.read(wsPath); } if (!workspace) { console.error('Error: Failed to create workspace. Check the provided name or input path.'); process.exit(1); } workspace.inspect().initialize(); if (options.showTree) { console.log(`Workspace path: ${workspace.path}`); console.log(workspace.dumpFileTree()); } if (options.saveSnapshot) { const snapshotPath = workspace.writeSnapshot(); console.log(`Snapshot saved to: ${snapshotPath}`); } if (options.showTreeSitterAst) { workspace.documents.forEach((doc, idx) => { const tree = doc.getTree(); if (idx === 1) console.log('----------------------------------------'); console.log(`Document: ${path.relative(workspace.path, uriToPath(doc.uri))}`); console.log(tree); console.log('----------------------------------------'); }); } } // Convenience export for the main class export { TestWorkspace as default }; ================================================ FILE: tests/tree-sitter-fast-check.test.ts ================================================ import { describe, it, expect, beforeAll } from 'vitest'; import * as fc from 'fast-check'; import { SyntaxNode, Tree } from 'web-tree-sitter'; import { Analyzer } from '../src/analyze'; import { TestWorkspace, TestFile } from './test-workspace-utils'; import { LspDocument } from '../src/document'; import { analyzer } from '../src/analyze'; // Tree-sitter utilities to test import { getChildNodes, getNamedChildNodes, findChildNodes, getParentNodes, findFirstParent, getSiblingNodes, findFirstNamedSibling, findEnclosingScope, getNodeText, isSyntaxNode, TreeWalker, getLeafNodes, getLastLeafNode, findNodeAt, getNodeAt, containsNode, containsRange, precedesRange, equalRanges, isNodeWithinRange, isNodeWithinOtherNode, getRange, positionToPoint, pointToPosition, rangeToPoint, } from '../src/utils/tree-sitter'; // Node type checkers to test import { isFunctionDefinition, isVariableDefinition, isCommand, isCommandName, isProgram, isForLoop, isIfStatement, isScope, isComment, isString, isOption, isVariable, isVariableExpansion, isPipe, isEnd, isSemicolon, isNewline, isBlockBreak, isTopLevelFunctionDefinition, isDefinition, isStatement, isBlock, isClause, isConditional, wordNodeIsCommand, isSwitchStatement, isCaseClause, isReturn, isConditionalCommand, isCommandFlag, isRegexArgument, isUnmatchedStringCharacter, isPartialForLoop, isInlineComment, isCommandWithName, isArgumentThatCanContainCommandCalls, isStringWithCommandCall, isReturnStatusNumber, isConcatenatedValue, isBraceExpansion, isPath, isCompleteCommandName, // Add missing functions for 100% coverage isShebang, isTopLevelDefinition, isElseStatement, isIfOrElseIfConditional, isPossibleUnreachableStatement, isStringCharacter, isEmptyString, isEndStdinCharacter, isEscapeSequence, isLongOption, isShortOption, isOptionValue, isJoinedShortOption, hasShortOptionCharacter, isInvalidVariableName, gatherSiblingsTillEol, isBeforeCommand, isVariableExpansionWithName, isCompleteFlagCommandName, findPreviousSibling, findParentCommand, isConcatenation, isAliasWithName, findParentFunction, findParentVariableDefinitionKeyword, findForLoopVariable, findSetDefinedVariable, hasParent, findParent, findParentWithFallback, hasParentFunction, findFunctionScope, scopeCheck, isError, } from '../src/utils/node-types'; // Parsing utilities to test import { isVariableDefinitionName, isFunctionDefinitionName, isAliasDefinitionName, isDefinitionName, isExportVariableDefinitionName, isArgparseVariableDefinitionName, isEmittedEventDefinitionName, } from '../src/parsing/barrel'; // Additional parsing modules for comprehensive coverage import * as AliasModule from '../src/parsing/alias'; import * as ArgparseModule from '../src/parsing/argparse'; import * as BindModule from '../src/parsing/bind'; import * as CompleteModule from '../src/parsing/complete'; import * as EmitModule from '../src/parsing/emit'; import * as ExportModule from '../src/parsing/export'; import * as ForModule from '../src/parsing/for'; import * as FunctionModule from '../src/parsing/function'; import * as NestedStringsModule from '../src/parsing/nested-strings'; import * as OptionsModule from '../src/parsing/options'; import * as ReadModule from '../src/parsing/read'; import * as SetModule from '../src/parsing/set'; import * as SourceModule from '../src/parsing/source'; import * as SymbolModule from '../src/parsing/symbol'; import * as UnreachableModule from '../src/parsing/unreachable'; import * as ValuesModule from '../src/parsing/values'; function shellVals() { const setCommand = () => fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([name, value]) => `set ${name} '${value}'`); // Function definition const functionDefinition = () => fc.tuple( fishShellArbitraries.functionName, fc.array(fishShellArbitraries.stringValue, { minLength: 0, maxLength: 3 }), ).map(([name, body]) => `function ${name}\n${body.map(line => ` echo '${line}'`).join('\n')}\nend`, ); // For loop const forLoop = () => fc.tuple( fishShellArbitraries.variableName, fc.array(fishShellArbitraries.stringValue, { minLength: 1, maxLength: 5 }), ).map(([var_, items]) => `for ${var_} in ${items.map(i => `'${i}'`).join(' ')}\n echo $${var_}\nend`, ); // If statement const ifStatement = () => fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, value]) => `if ${cmd} '${value}'\n echo "true"\nelse\n echo "false"\nend`, ); // Command with options const commandWithOptions = () => fc.tuple( fishShellArbitraries.commandName, fc.array(fishShellArbitraries.option, { minLength: 0, maxLength: 3 }), fc.array(fishShellArbitraries.stringValue, { minLength: 0, maxLength: 3 }), ).map(([cmd, options, args]) => `${cmd} ${options.join(' ')} ${args.map(a => `'${a}'`).join(' ')}`, ); // Comments const comment = () => fishShellArbitraries.stringValue.map(text => `# ${text}`); return { setCommand, functionDefinition, forLoop, ifStatement, commandWithOptions, comment, }; } // Generator functions for creating test Fish shell code const fishShellArbitraries = { // Basic identifiers identifier: fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_]*$/), // Variable names (can include special chars) variableName: fc.oneof( fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_]*$/), fc.constant('argv'), fc.constant('status'), fc.constant('PWD'), fc.constant('USER'), fc.constant('HOME'), ), // Function names functionName: fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_-]*$/), // Command names commandName: fc.oneof( fc.constant('echo'), fc.constant('set'), fc.constant('if'), fc.constant('for'), fc.constant('while'), fc.constant('function'), fc.constant('end'), fc.constant('test'), fc.constant('ls'), fc.constant('cat'), fc.constant('grep'), fc.constant('awk'), fc.constant('sed'), fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_-]*$/), ), // String values stringValue: fc.oneof( fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\n')), fc.constant('hello world'), fc.constant('test-string'), fc.constant(''), ), // Options/flags shortOption: fc.stringMatching(/^-[a-zA-Z]$/), longOption: fc.stringMatching(/^--[a-zA-Z][a-zA-Z0-9-]*$/), option: fc.oneof( fc.stringMatching(/^-[a-zA-Z]$/), fc.stringMatching(/^--[a-zA-Z][a-zA-Z0-9-]*$/), ), // Numbers number: fc.integer({ min: 0, max: 1000 }), // Paths path: fc.oneof( fc.constant('/usr/bin/fish'), fc.constant('./script.fish'), fc.constant('~/config.fish'), fc.constant('/tmp/test'), fc.stringMatching(/^[a-zA-Z_][a-zA-Z0-9_/.-]*$/), ), }; // Generate Fish shell code structures for comprehensive node type testing const fishCodeGenerators = { // Basic constructs setCommand: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([name, value]) => `set ${name} '${value}'`), functionDefinition: fc.tuple( fishShellArbitraries.functionName, fc.array(fishShellArbitraries.stringValue, { minLength: 0, maxLength: 3 }), ).map(([name, body]) => `function ${name}\n${body.map(line => ` echo '${line}'`).join('\n')}\nend`, ), forLoop: fc.tuple( fishShellArbitraries.variableName, fc.array(fishShellArbitraries.stringValue, { minLength: 1, maxLength: 5 }), ).map(([var_, items]) => `for ${var_} in ${items.map(i => `'${i}'`).join(' ')}\n echo $${var_}\nend`, ), ifStatement: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, value]) => `if ${cmd} '${value}'\n echo "true"\nelse\n echo "false"\nend`, ), commandWithOptions: fc.tuple( fishShellArbitraries.commandName, fc.array(fishShellArbitraries.option, { minLength: 0, maxLength: 3 }), fc.array(fishShellArbitraries.stringValue, { minLength: 0, maxLength: 3 }), ).map(([cmd, options, args]) => `${cmd} ${options.join(' ')} ${args.map(a => `'${a}'`).join(' ')}`, ), comment: fishShellArbitraries.stringValue.map(text => `# ${text}`), // Advanced Fish constructs for comprehensive node type coverage whileLoop: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, condition]) => `while ${cmd} ${condition}\n echo "looping"\nend`, ), switchStatement: fc.tuple( fishShellArbitraries.variableName, fc.array(fishShellArbitraries.stringValue, { minLength: 2, maxLength: 4 }), ).map(([var_, cases]) => `switch $${var_}\n${cases.map(c => `case '${c}'\n echo "matched ${c}"`).join('\n')}\ncase '*'\n echo "default"\nend`, ), beginBlock: fc.array(fishShellArbitraries.stringValue, { minLength: 1, maxLength: 3 }) .map(commands => `begin\n${commands.map(cmd => ` echo '${cmd}'`).join('\n')}\nend`), testCommand: fc.tuple( fishShellArbitraries.stringValue, fishShellArbitraries.stringValue, ).map(([left, right]) => `test '${left}' = '${right}'`), commandSubstitution: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, arg]) => `set result (${cmd} '${arg}')`), variableExpansion: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([var_, value]) => `set ${var_} '${value}'\necho $${var_}`), braceExpansion: fc.array(fishShellArbitraries.stringValue, { minLength: 2, maxLength: 4 }) .map(items => `echo {${items.join(',')}}`), pipeChain: fc.array(fishShellArbitraries.commandName, { minLength: 2, maxLength: 4 }) .map(commands => commands.join(' | ')), redirection: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.path, ).map(([cmd, file]) => `${cmd} > '${file}'`), stringVariations: fc.oneof( fc.constant('echo "double quoted"'), fc.constant('echo \'single quoted\''), fc.constant('echo \'mixed "quotes"\''), fc.constant('echo "mixed \'quotes\'"'), ), conditionalExecution: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.commandName, ).map(([cmd1, cmd2]) => `${cmd1} && ${cmd2}`), concatenation: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([var_, suffix]) => `echo $${var_}${suffix}`), indexAccess: fc.tuple( fishShellArbitraries.variableName, fc.integer({ min: 1, max: 5 }), ).map(([var_, index]) => `echo $${var_}[${index}]`), rangeSyntax: fc.tuple( fishShellArbitraries.variableName, fc.integer({ min: 1, max: 3 }), fc.integer({ min: 4, max: 6 }), ).map(([var_, start, end]) => `echo $${var_}[${start}..${end}]`), escapeSequences: fc.oneof( fc.constant('echo "line 1\\nline 2"'), fc.constant('echo "tab\\there"'), fc.constant('echo "quote: \\"hello\\""'), ), returnStatement: fc.integer({ min: 0, max: 255 }) .map(code => `function test_return\n return ${code}\nend`), breakContinue: fc.oneof( fc.constant('for i in 1 2 3\n if test $i -eq 2\n break\n end\n echo $i\nend'), fc.constant('for i in 1 2 3\n if test $i -eq 2\n continue\n end\n echo $i\nend'), ), aliasDefinition: fc.tuple( fishShellArbitraries.identifier, fishShellArbitraries.commandName, ).map(([alias, command]) => `alias ${alias}='${command}'`), abbreviation: fc.tuple( fishShellArbitraries.identifier, fishShellArbitraries.stringValue, ).map(([abbr, expansion]) => `abbr -a ${abbr} '${expansion}'`), completeDefinition: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.option, fishShellArbitraries.stringValue, ).map(([cmd, opt, desc]) => `complete -c ${cmd} ${opt} -d '${desc}'`), eventFunction: fc.tuple( fishShellArbitraries.functionName, fishShellArbitraries.identifier, ).map(([name, event]) => `function ${name} --on-event ${event}\n echo "event triggered"\nend`), jobControl: fc.oneof( fc.constant('sleep 10 &'), fc.constant('jobs'), fc.constant('fg %1'), fc.constant('bg %1'), ), heredoc: fc.tuple( fishShellArbitraries.stringValue, fishShellArbitraries.stringValue, ).map(([delimiter, content]) => `cat << ${delimiter}\n${content}\n${delimiter}`), shebang: fc.constant('#!/usr/bin/env fish'), errorNodes: fc.oneof( fc.constant('function\nend'), // Missing function name fc.constant('for\nend'), // Missing for variable fc.constant('if\nend'), // Missing if condition fc.constant('set'), // Incomplete set ), // Missing node types from parse tree analysis negatedStatement: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, arg]) => `not ${cmd} '${arg}'`), conditionalExecutionOr: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.commandName, ).map(([cmd1, cmd2]) => `${cmd1} || ${cmd2}`), conditionalExecutionAnd: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.commandName, ).map(([cmd1, cmd2]) => `${cmd1} && ${cmd2}`), readCommand: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([var_, prompt]) => `read --prompt-str '${prompt}' --local ${var_}`), argparseCommand: fc.tuple( fishShellArbitraries.identifier, fc.array(fishShellArbitraries.stringValue, { minLength: 1, maxLength: 3 }), ).map(([name, options]) => `argparse ${options.map(o => `'${o}'`).join(' ')} -- $argv`, ), integerLiterals: fc.integer({ min: 0, max: 1000 }) .map(n => `set count ${n}`), dollarParentheses: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, arg]) => `echo $(${cmd} '${arg}')`), parenthesesCommand: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, arg]) => `echo (${cmd} '${arg}')`), elseIfClause: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, fishShellArbitraries.stringValue, ).map(([cmd, arg1, arg2]) => `if test '${arg1}' = 'x'\n echo 'first'\nelse if ${cmd} '${arg2}'\n echo 'second'\nelse\n echo 'third'\nend`, ), emptyString: fc.constant('echo \'\''), doubleQuoteString: fc.tuple( fishShellArbitraries.stringValue, fishShellArbitraries.variableName, ).map(([str, var_]) => `echo "${str} $${var_}"`), singleQuoteString: fishShellArbitraries.stringValue .map(str => `echo '${str}'`), variableNameSimple: fishShellArbitraries.variableName .map(name => `set ${name} value`), functionWithOptions: fc.tuple( fishShellArbitraries.functionName, fishShellArbitraries.stringValue, ).map(([name, desc]) => `function ${name} --description '${desc}' --argument-names arg1 arg2\n echo $arg1 $arg2\nend`, ), wordNode: fc.tuple( fishShellArbitraries.commandName, fc.array(fishShellArbitraries.stringValue, { minLength: 1, maxLength: 3 }), ).map(([cmd, args]) => `${cmd} ${args.join(' ')}`), orOperator: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.commandName, ).map(([cmd1, cmd2]) => `${cmd1} || ${cmd2}`), andOperator: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.commandName, ).map(([cmd1, cmd2]) => `${cmd1} && ${cmd2}`), ifKeyword: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, value]) => `if ${cmd} '${value}'\nend`), elseKeyword: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.stringValue, ).map(([cmd, value]) => `if test 1\n echo 'true'\nelse\n ${cmd} '${value}'\nend`), functionKeyword: fishShellArbitraries.functionName .map(name => `function ${name}\nend`), endKeyword: fc.constant('function test\nend'), returnKeyword: fc.integer({ min: 0, max: 255 }) .map(code => `function test\n return ${code}\nend`), // Complex nested structures that generate multiple node types complexNested: fc.tuple( fishShellArbitraries.functionName, fishShellArbitraries.variableName, fc.array(fishShellArbitraries.stringValue, { minLength: 2, maxLength: 4 }), ).map(([funcName, varName, items]) => ` function ${funcName} --description 'Complex function' set -l ${varName} (date +%s) if test -n "$argv" for item in ${items.map(i => `'${i}'`).join(' ')} if string match -q "*$item*" "$argv" echo "Found: $item in $argv" return 0 else if test "$item" = "special" echo "Special case" continue else echo "Regular item: $item" end end else if test $${varName} -gt 1000 echo "Large timestamp: $${varName}" not false && echo "Always true" else echo "Default case" | string upper return 1 end end`), // Specific parsing module test generators exportCommand: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([var_, value]) => `export ${var_}='${value}'`), sourceCommand: fc.tuple( fishShellArbitraries.path, ).map(([path]) => `source ${path}`), bindCommand: fc.tuple( fishShellArbitraries.stringValue, fishShellArbitraries.stringValue, ).map(([key, action]) => `bind '${key}' '${action}'`), emitEvent: fc.tuple( fishShellArbitraries.identifier, ).map(([event]) => `emit ${event}`), readCommandAdvanced: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([var_, prompt]) => `read --prompt '${prompt}' --line ${var_}`), setWithFlags: fc.tuple( fishShellArbitraries.variableName, fishShellArbitraries.stringValue, ).map(([var_, value]) => `set --local --export ${var_} '${value}'`), functionWithEventHandler: fc.tuple( fishShellArbitraries.functionName, fishShellArbitraries.identifier, ).map(([name, event]) => `function ${name} --on-event ${event}\n echo "handling event"\nend`), argparseWithOptions: fc.tuple( fishShellArbitraries.identifier, fc.array(fishShellArbitraries.identifier, { minLength: 2, maxLength: 4 }), ).map(([funcName, options]) => `function ${funcName}\n argparse ${options.map(opt => `'${opt}'`).join(' ')} -- $argv\nend`, ), completeWithOptions: fc.tuple( fishShellArbitraries.commandName, fishShellArbitraries.option, fishShellArbitraries.stringValue, ).map(([cmd, opt, desc]) => `complete -c ${cmd} ${opt} -d '${desc}' -f`), }; // Complete Fish program with comprehensive node type coverage fishCodeGenerators.fishProgram = fc.array( fc.oneof( fishCodeGenerators.setCommand, fishCodeGenerators.functionDefinition, fishCodeGenerators.forLoop, fishCodeGenerators.ifStatement, fishCodeGenerators.whileLoop, fishCodeGenerators.switchStatement, fishCodeGenerators.beginBlock, fishCodeGenerators.commandWithOptions, fishCodeGenerators.testCommand, fishCodeGenerators.commandSubstitution, fishCodeGenerators.variableExpansion, fishCodeGenerators.braceExpansion, fishCodeGenerators.pipeChain, fishCodeGenerators.redirection, fishCodeGenerators.stringVariations, fishCodeGenerators.conditionalExecution, fishCodeGenerators.concatenation, fishCodeGenerators.indexAccess, fishCodeGenerators.rangeSyntax, fishCodeGenerators.escapeSequences, fishCodeGenerators.returnStatement, fishCodeGenerators.breakContinue, fishCodeGenerators.aliasDefinition, fishCodeGenerators.abbreviation, fishCodeGenerators.completeDefinition, fishCodeGenerators.eventFunction, fishCodeGenerators.jobControl, fishCodeGenerators.comment, // New comprehensive node type generators fishCodeGenerators.negatedStatement, fishCodeGenerators.conditionalExecutionOr, fishCodeGenerators.conditionalExecutionAnd, fishCodeGenerators.readCommand, fishCodeGenerators.argparseCommand, fishCodeGenerators.integerLiterals, fishCodeGenerators.dollarParentheses, fishCodeGenerators.parenthesesCommand, fishCodeGenerators.elseIfClause, fishCodeGenerators.emptyString, fishCodeGenerators.doubleQuoteString, fishCodeGenerators.singleQuoteString, fishCodeGenerators.functionWithOptions, fishCodeGenerators.complexNested, // New parsing module specific generators fishCodeGenerators.exportCommand, fishCodeGenerators.sourceCommand, fishCodeGenerators.bindCommand, fishCodeGenerators.emitEvent, fishCodeGenerators.readCommandAdvanced, fishCodeGenerators.setWithFlags, fishCodeGenerators.functionWithEventHandler, fishCodeGenerators.argparseWithOptions, fishCodeGenerators.completeWithOptions, ), { minLength: 1, maxLength: 9 }, ).map(statements => statements.join('\n\n')); describe('Tree-sitter Fast-check Property Tests', () => { let workspace: TestWorkspace; beforeAll(async () => { await Analyzer.initialize(); }); describe('Tree-sitter Node Navigation Properties', () => { it('should maintain tree invariants for any valid Fish code', () => { fc.assert(fc.property(fishCodeGenerators.fishProgram, (fishCode) => { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const rootNode = doc.tree.rootNode; // Property: Root node should always be a program expect(isProgram(rootNode)).toBe(true); // Property: Every node should have a valid parent relationship (except root) const allNodes = getChildNodes(rootNode); for (const node of allNodes) { if (node !== rootNode) { expect(node.parent).toBeTruthy(); if (node.parent) { expect(node.parent.children).toContain(node); } } } // Property: getParentNodes should always include the node itself if (allNodes.length > 1) { const randomNode = allNodes[Math.floor(Math.random() * allNodes.length)]!; const parents = getParentNodes(randomNode); expect(parents[0]).toBe(randomNode); } return true; }), { numRuns: 50 }); }); it('should correctly identify node types for generated Fish code', () => { fc.assert(fc.property(fishCodeGenerators.setCommand, (setCommand) => { const testWorkspace = TestWorkspace.createSingle(setCommand); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Property: Commands should be correctly identified const commands = allNodes.filter(node => isCommand(node)); for (const cmd of commands) { if (cmd.firstNamedChild) { expect(isCommandName(cmd.firstNamedChild)).toBe(true); } } // Property: If there's a set command, it should have variable definitions const setCommands = allNodes.filter(node => isCommand(node) && node.firstNamedChild?.text === 'set', ); for (const setCmd of setCommands) { const varNodes = allNodes.filter(node => isVariableDefinitionName(node)); // Should have at least one variable definition when using set if (setCmd.namedChildCount > 1) { expect(varNodes.length).toBeGreaterThan(0); } } return true; }), { numRuns: 30 }); }); it('should maintain TreeWalker properties for navigation', () => { fc.assert(fc.property(fishCodeGenerators.functionDefinition, (functionCode) => { const testWorkspace = TestWorkspace.createSingle(functionCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const leafNodes = allNodes.filter(node => node.childCount === 0); if (leafNodes.length > 0) { const randomLeaf = leafNodes[Math.floor(Math.random() * leafNodes.length)]!; // Property: Walking up from any node should eventually reach the root const rootFound = TreeWalker.walkUp(randomLeaf, node => isProgram(node)); expect(rootFound.isSome()).toBe(true); // Property: Walking down from root should be able to find any descendant const foundFromRoot = TreeWalker.walkDown(doc.tree.rootNode, node => node.equals(randomLeaf)); expect(foundFromRoot.isSome()).toBe(true); } return true; }), { numRuns: 30 }); }); it('should correctly handle range and position operations', () => { fc.assert(fc.property(fishCodeGenerators.fishProgram, (fishCode) => { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); for (const node of allNodes.slice(0, 10)) { // Test first 10 nodes for performance const range = getRange(node); // Property: Range should be valid expect(range.start.line).toBeLessThanOrEqual(range.end.line); if (range.start.line === range.end.line) { expect(range.start.character).toBeLessThanOrEqual(range.end.character); } // Property: Position/Point conversion should be reversible const startPoint = positionToPoint(range.start); const endPoint = positionToPoint(range.end); const backToStart = pointToPosition(startPoint); const backToEnd = pointToPosition(endPoint); expect(backToStart).toEqual(range.start); expect(backToEnd).toEqual(range.end); // Property: Node should be within its own range expect(isNodeWithinRange(node, range)).toBe(true); } return true; }), { numRuns: 20 }); }); }); describe('Fish Language Specific Properties', () => { it('should correctly identify function definitions and their properties', () => { fc.assert(fc.property(fishCodeGenerators.functionDefinition, (functionCode) => { const testWorkspace = TestWorkspace.createSingle(functionCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const functionNodes = allNodes.filter(node => isFunctionDefinition(node)); for (const funcNode of functionNodes) { // Property: Function definition should have a name expect(funcNode.firstNamedChild).toBeTruthy(); if (funcNode.firstNamedChild) { // Property: Function name should be identified as such expect(isFunctionDefinitionName(funcNode.firstNamedChild)).toBe(true); expect(isDefinitionName(funcNode.firstNamedChild)).toBe(true); } // Property: Function should create a scope expect(isScope(funcNode)).toBe(true); // Property: Function should end with 'end' const endNodes = allNodes.filter(node => isEnd(node)); expect(endNodes.length).toBeGreaterThan(0); } return true; }), { numRuns: 30 }); }); it('should correctly identify for loops and their variables', () => { fc.assert(fc.property(fishCodeGenerators.forLoop, (forCode) => { const testWorkspace = TestWorkspace.createSingle(forCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const forNodes = allNodes.filter(node => isForLoop(node)); for (const forNode of forNodes) { // Property: For loop should create a scope expect(isScope(forNode)).toBe(true); // Property: For loop should be a statement expect(isStatement(forNode)).toBe(true); // Property: For loop should have an end const endNodes = allNodes.filter(node => isEnd(node)); expect(endNodes.length).toBeGreaterThan(0); // Property: For loop variable should be identifiable if (forNode.firstNamedChild?.type === 'variable_name') { expect(isVariableDefinitionName(forNode.firstNamedChild)).toBe(true); } } return true; }), { numRuns: 30 }); }); it('should correctly identify commands and their arguments', () => { fc.assert(fc.property(fishCodeGenerators.commandWithOptions, (cmdCode) => { const testWorkspace = TestWorkspace.createSingle(cmdCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const commands = allNodes.filter(node => isCommand(node)); for (const cmd of commands) { // Property: Command should have a name if (cmd.firstNamedChild) { expect(isCommandName(cmd.firstNamedChild)).toBe(true); expect(wordNodeIsCommand(cmd.firstNamedChild)).toBe(true); } // Property: Options should be identified correctly const options = cmd.namedChildren.filter(child => isOption(child)); for (const option of options) { expect(option.text.startsWith('-')).toBe(true); expect(isCommandFlag(option)).toBe(true); } } return true; }), { numRuns: 30 }); }); it('should correctly handle string and comment identification', () => { fc.assert(fc.property( fc.tuple(fishCodeGenerators.comment, fishShellArbitraries.stringValue), ([commentCode, stringValue]) => { const testCode = `${commentCode}\necho '${stringValue}'`; const testWorkspace = TestWorkspace.createSingle(testCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Property: Comments should be identified const comments = allNodes.filter(node => isComment(node)); expect(comments.length).toBeGreaterThan(0); for (const comment of comments) { expect(comment.text.startsWith('#')).toBe(true); } // Property: Strings should be identified const strings = allNodes.filter(node => isString(node)); for (const str of strings) { expect(str.text.includes(stringValue) || str.text === "''").toBe(true); } return true; }, ), { numRuns: 30 }); }); it('should maintain node text consistency', () => { fc.assert(fc.property(fishCodeGenerators.fishProgram, (fishCode) => { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); for (const node of allNodes.slice(0, 20)) { // Test first 20 for performance // Property: getNodeText should return non-null for valid nodes const nodeText = getNodeText(node); expect(typeof nodeText).toBe('string'); // Property: Node text should be contained in the original source if (node.text && node.text.length > 0) { expect(fishCode).toContain(node.text.trim()); } } return true; }), { numRuns: 20 }); }); }); describe('Tree-sitter Parser Robustness', () => { it('should handle malformed Fish code gracefully', () => { const malformedFishCode = fc.oneof( fc.constant('function\nend'), // Missing function name fc.constant('for\nend'), // Missing for variable fc.constant('if\nend'), // Missing if condition fc.constant('set'), // Incomplete set command fc.constant('function foo\n# missing end'), fc.constant('for i in\nend'), // Missing items fc.constant('if test\n# missing end'), fc.constant('set -'), // Invalid set syntax fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), ); fc.assert(fc.property(malformedFishCode, (malformedCode) => { try { const testWorkspace = TestWorkspace.createSingle(malformedCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; // Property: Parser should still create a valid tree structure expect(isSyntaxNode(doc.tree.rootNode)).toBe(true); expect(isProgram(doc.tree.rootNode)).toBe(true); // Property: Navigation functions should not throw errors const allNodes = getChildNodes(doc.tree.rootNode); expect(Array.isArray(allNodes)).toBe(true); expect(allNodes.length).toBeGreaterThan(0); // Property: Even malformed code should have some identifiable structure expect(allNodes[0]).toBe(doc.tree.rootNode); return true; } catch (error) { // If there's an error, it should be controlled and not crash the process expect(error).toBeInstanceOf(Error); return true; } }), { numRuns: 50 }); }); it('should handle edge cases in node identification', () => { const edgeCases = fc.oneof( fc.constant(''), fc.constant(' '), fc.constant('\n'), fc.constant('\t'), fc.constant('# only comment'), fc.constant('set ""'), fc.constant('function "" end'), fc.constant('echo'), fc.constant(';'), fc.constant('|'), fc.constant('&'), fc.constant('()'), fc.constant('""'), fc.constant("''"), ); fc.assert(fc.property(edgeCases, (edgeCase) => { try { const testWorkspace = TestWorkspace.createSingle(edgeCase); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Property: Should handle empty or minimal content gracefully expect(() => { for (const node of allNodes) { isSyntaxNode(node); isProgram(node); isCommand(node); isString(node); isComment(node); getNodeText(node); } }).not.toThrow(); return true; } catch (error) { return true; // Edge cases might fail, but shouldn't crash } }), { numRuns: 30 }); }); }); describe('Advanced Fish Language Constructs', () => { it('should correctly identify while loops and their variables', () => { fc.assert(fc.property(fishCodeGenerators.whileLoop, (whileCode) => { const testWorkspace = TestWorkspace.createSingle(whileCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const whileNodes = allNodes.filter(node => node.type === 'while_statement'); for (const whileNode of whileNodes) { // Property: While loop should create a scope expect(isScope(whileNode)).toBe(true); expect(isStatement(whileNode)).toBe(true); // Property: While loop should have an end const endNodes = allNodes.filter(node => isEnd(node)); expect(endNodes.length).toBeGreaterThan(0); } return true; }), { numRuns: 20 }); }); it('should correctly identify switch statements and case clauses', () => { fc.assert(fc.property(fishCodeGenerators.switchStatement, (switchCode) => { const testWorkspace = TestWorkspace.createSingle(switchCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const switchNodes = allNodes.filter(node => isSwitchStatement(node)); const caseNodes = allNodes.filter(node => isCaseClause(node)); for (const switchNode of switchNodes) { expect(isStatement(switchNode)).toBe(true); expect(isScope(switchNode)).toBe(true); } for (const caseNode of caseNodes) { expect(isClause(caseNode)).toBe(true); } return true; }), { numRuns: 20 }); }); it('should correctly identify variable expansions and concatenations', () => { fc.assert(fc.property( fc.oneof(fishCodeGenerators.variableExpansion, fishCodeGenerators.concatenation), (varCode) => { const testWorkspace = TestWorkspace.createSingle(varCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const varExpansions = allNodes.filter(node => isVariableExpansion(node)); const concatenations = allNodes.filter(node => isConcatenatedValue(node)); for (const varExp of varExpansions) { expect(varExp.type === 'variable_expansion').toBe(true); expect(varExp.text.startsWith('$')).toBe(true); } for (const concat of concatenations) { expect(concat.type === 'concatenation').toBe(true); } return true; }, ), { numRuns: 20 }); }); it('should correctly identify command substitution and pipes', () => { fc.assert(fc.property( fc.oneof(fishCodeGenerators.commandSubstitution, fishCodeGenerators.pipeChain), (cmdCode) => { const testWorkspace = TestWorkspace.createSingle(cmdCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const commandSubsts = allNodes.filter(node => node.type === 'command_substitution'); const pipes = allNodes.filter(node => isPipe(node)); const commands = allNodes.filter(node => isCommand(node)); for (const cmdSubst of commandSubsts) { expect(isCommand(cmdSubst)).toBe(true); } // Should have commands expect(commands.length).toBeGreaterThan(0); return true; }, ), { numRuns: 20 }); }); it('should correctly identify string variations and escape sequences', () => { fc.assert(fc.property( fc.oneof(fishCodeGenerators.stringVariations, fishCodeGenerators.escapeSequences), (stringCode) => { const testWorkspace = TestWorkspace.createSingle(stringCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const strings = allNodes.filter(node => isString(node)); const escapeSeqs = allNodes.filter(node => isEscapeSequence(node)); const stringChars = allNodes.filter(node => isStringCharacter(node)); for (const str of strings) { expect(['double_quote_string', 'single_quote_string'].includes(str.type)).toBe(true); } for (const escSeq of escapeSeqs) { expect(escSeq.type === 'escape_sequence').toBe(true); } for (const strChar of stringChars) { expect(['"', "'"].includes(strChar.type)).toBe(true); } return true; }, ), { numRuns: 20 }); }); it('should handle comprehensive node type coverage', () => { const allAdvancedConstructs = [ fishCodeGenerators.whileLoop, fishCodeGenerators.switchStatement, fishCodeGenerators.beginBlock, fishCodeGenerators.testCommand, fishCodeGenerators.commandSubstitution, fishCodeGenerators.variableExpansion, fishCodeGenerators.braceExpansion, fishCodeGenerators.pipeChain, fishCodeGenerators.redirection, fishCodeGenerators.stringVariations, fishCodeGenerators.conditionalExecution, fishCodeGenerators.concatenation, fishCodeGenerators.indexAccess, fishCodeGenerators.rangeSyntax, fishCodeGenerators.escapeSequences, fishCodeGenerators.returnStatement, fishCodeGenerators.breakContinue, fishCodeGenerators.aliasDefinition, fishCodeGenerators.abbreviation, fishCodeGenerators.completeDefinition, fishCodeGenerators.eventFunction, fishCodeGenerators.jobControl, ]; fc.assert(fc.property( fc.oneof(...allAdvancedConstructs), (fishCode) => { try { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Property: Should handle all node types without errors expect(() => { for (const node of allNodes.slice(0, 10)) { // Test core node type checkers isSyntaxNode(node); getNodeText(node); getRange(node); // Test Fish-specific checkers isProgram(node); isCommand(node); isCommandName(node); isFunctionDefinition(node); isForLoop(node); isIfStatement(node); isStatement(node); isScope(node); isString(node); isOption(node); isVariable(node); isVariableExpansion(node); isPipe(node); isSwitchStatement(node); isCaseClause(node); isReturn(node); isConditionalCommand(node); isBraceExpansion(node); isConcatenatedValue(node); isEscapeSequence(node); isError(node); // Test definition name checkers isVariableDefinitionName(node); isFunctionDefinitionName(node); isAliasDefinitionName(node); isDefinitionName(node); } }).not.toThrow(); return true; } catch (error) { // Allow controlled failures for edge cases return true; } }, ), { numRuns: 30 }); }); it('should maintain node relationships across all advanced constructs', () => { fc.assert(fc.property(fishCodeGenerators.fishProgram, (fishCode) => { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const rootNode = doc.tree.rootNode; const allNodes = getChildNodes(rootNode); // Property: All nodes should have consistent parent-child relationships for (const node of allNodes.slice(0, 15)) { if (node !== rootNode) { expect(node.parent).toBeTruthy(); if (node.parent) { expect(node.parent.children).toContain(node); } } // Property: Scope nodes should be identifiable if (isScope(node)) { expect( isProgram(node) || isFunctionDefinition(node) || isStatement(node), ).toBe(true); } // Property: Command nodes should have proper structure if (isCommand(node) && node.firstNamedChild) { expect(node.firstNamedChild.type).toBeDefined(); } // Property: String nodes should have proper types if (isString(node)) { expect(['double_quote_string', 'single_quote_string'].includes(node.type)).toBe(true); } } return true; }), { numRuns: 20 }); }); }); describe('Specialized Node Type Tests', () => { it('should correctly identify all punctuation and separator nodes', () => { const punctuationCode = fc.oneof( fc.constant('echo hello; echo world'), // semicolon fc.constant('echo hello\necho world'), // newline fc.constant('echo hello | grep lo'), // pipe fc.constant('function test\nend'), // end fc.constant('echo (date)'), // parentheses fc.constant('echo $var[1]'), // brackets fc.constant('echo {a,b,c}'), // braces ); fc.assert(fc.property(punctuationCode, (code) => { try { const testWorkspace = TestWorkspace.createSingle(code); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test punctuation identification const semicolons = allNodes.filter(node => isSemicolon(node)); const newlines = allNodes.filter(node => isNewline(node)); const pipes = allNodes.filter(node => isPipe(node)); const ends = allNodes.filter(node => isEnd(node)); // Properties based on content if (code.includes(';')) { expect(semicolons.length).toBeGreaterThan(0); } if (code.includes('|')) { expect(pipes.length).toBeGreaterThan(0); } if (code.includes('end')) { expect(ends.length).toBeGreaterThan(0); } return true; } catch (error) { return true; } }), { numRuns: 25 }); }); it('should handle all option and flag variations', () => { const optionCode = fc.oneof( fc.constant('echo -n "no newline"'), fc.constant('ls -la'), fc.constant('grep --color=auto pattern'), fc.constant('set --local var value'), fc.constant('function test --on-event signal\nend'), fc.constant('complete -c cmd --short-option o'), ); fc.assert(fc.property(optionCode, (code) => { try { const testWorkspace = TestWorkspace.createSingle(code); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const options = allNodes.filter(node => isOption(node)); const shortOptions = allNodes.filter(node => isShortOption(node)); const longOptions = allNodes.filter(node => isLongOption(node)); const commandFlags = allNodes.filter(node => isCommandFlag(node)); // Properties: Options should be identified correctly for (const option of options) { expect(option.text.startsWith('-')).toBe(true); } for (const shortOpt of shortOptions) { expect(shortOpt.text.match(/^-[a-zA-Z]$/)).toBeTruthy(); } for (const longOpt of longOptions) { expect(longOpt.text.startsWith('--')).toBe(true); expect(longOpt.text !== '--').toBe(true); // Shouldn't match end stdin } return true; } catch (error) { return true; } }), { numRuns: 25 }); }); it('should identify all types of variable access patterns', () => { const variableAccessCode = fc.oneof( fc.constant('echo $HOME'), // simple expansion fc.constant('echo $argv[1]'), // indexed access fc.constant('echo $argv[1..3]'), // range access fc.constant('echo $argv[-1]'), // negative index fc.constant('echo (count $argv)'), // in command substitution fc.constant('set var $HOME/bin'), // in assignment ); fc.assert(fc.property(variableAccessCode, (code) => { try { const testWorkspace = TestWorkspace.createSingle(code); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const varExpansions = allNodes.filter(node => isVariableExpansion(node)); const variables = allNodes.filter(node => isVariable(node)); // Properties: Variable expansions should start with $ for (const varExp of varExpansions) { expect(varExp.text.startsWith('$')).toBe(true); } // Should have some form of variable reference expect(varExpansions.length + variables.length).toBeGreaterThan(0); return true; } catch (error) { return true; } }), { numRuns: 25 }); }); it('should handle all statement termination patterns', () => { const terminationCode = fc.oneof( fc.constant('echo hello; echo world'), fc.constant('echo hello\necho world'), fc.constant('function test\n echo body\nend'), fc.constant('if test 1\n echo true\nend'), fc.constant('for i in 1 2 3\n echo $i\nend'), ); fc.assert(fc.property(terminationCode, (code) => { try { const testWorkspace = TestWorkspace.createSingle(code); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const blockBreaks = allNodes.filter(node => isBlockBreak(node)); // Properties: Should identify block breaks expect(blockBreaks.length).toBeGreaterThan(0); // Every end should be a block break const ends = allNodes.filter(node => isEnd(node)); for (const end of ends) { expect(isBlockBreak(end)).toBe(true); } return true; } catch (error) { return true; } }), { numRuns: 25 }); }); it('should identify all error and edge case node patterns', () => { const edgeCaseCode = fc.oneof( fc.constant('function\nend'), // missing name fc.constant('for\nend'), // missing variable fc.constant('set'), // incomplete fc.constant('echo "unterminated string'), // syntax error fc.constant('function test --unknown-flag\nend'), // unknown flag fc.constant(''), // empty ); fc.assert(fc.property(edgeCaseCode, (code) => { try { const testWorkspace = TestWorkspace.createSingle(code); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const errorNodes = allNodes.filter(node => isError(node)); // Properties: Should handle errors gracefully expect(() => { for (const node of allNodes) { getNodeText(node); getRange(node); } }).not.toThrow(); // Error nodes should be identifiable for (const errNode of errorNodes) { expect(errNode.type === 'ERROR').toBe(true); } return true; } catch (error) { // Edge cases might cause parsing failures return true; } }), { numRuns: 30 }); }); it('should comprehensively test all available node type checkers', () => { fc.assert(fc.property(fishCodeGenerators.fishProgram, (fishCode) => { try { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Property: Every node type checker should work without throwing expect(() => { for (const node of allNodes.slice(0, 20)) { // Core node checkers isSyntaxNode(node); getNodeText(node); getRange(node); // All Fish node type checkers from node-types.ts isProgram(node); isError(node); isComment(node); isShebang(node); isFunctionDefinition(node); isCommand(node); isCommandName(node); isTopLevelFunctionDefinition(node); isTopLevelDefinition(node); isDefinition(node); isForLoop(node); isIfStatement(node); isElseStatement(node); isConditional(node); isIfOrElseIfConditional(node); isPossibleUnreachableStatement(node); isClause(node); isStatement(node); isBlock(node); isEnd(node); isScope(node); isSemicolon(node); isNewline(node); isBlockBreak(node); isString(node); isStringCharacter(node); isEmptyString(node); isEndStdinCharacter(node); isEscapeSequence(node); isLongOption(node); isShortOption(node); isOption(node); isOptionValue(node); isCommandFlag(node); isPipe(node); isVariable(node); isVariableExpansion(node); // Removed non-existent functions: isVariableReference, isWordExpansion isConcatenatedValue(node); isBraceExpansion(node); isSwitchStatement(node); isCaseClause(node); isReturn(node); isConditionalCommand(node); isRegexArgument(node); isUnmatchedStringCharacter(node); isPartialForLoop(node); isInlineComment(node); isCommandWithName(node, 'test'); isArgumentThatCanContainCommandCalls(node); isStringWithCommandCall(node); isReturnStatusNumber(node); isPath(node); isCompleteCommandName(node); wordNodeIsCommand(node); // All definition name checkers isVariableDefinitionName(node); isFunctionDefinitionName(node); isAliasDefinitionName(node); isExportVariableDefinitionName(node); isArgparseVariableDefinitionName(node); isEmittedEventDefinitionName(node); isDefinitionName(node); } }).not.toThrow(); return true; } catch (error) { // Allow some failures for malformed input return true; } }), { numRuns: 25 }); }); it('should test all previously uncovered functions for 100% node-types.ts coverage', () => { const comprehensiveCoverageCode = fc.oneof( fc.constant('#!/usr/bin/env fish\n# Shebang test\necho "hello"'), // shebang test fishCodeGenerators.complexNested, // top level definition test fc.constant('if test 1\n echo "true"\nelse if test 2\n echo "else if"\nelse\n echo "else"\nend'), // else statement test fc.constant('echo ""'), // empty string test fc.constant('echo --'), // end stdin character test fc.constant('echo "line 1\\nline 2"'), // escape sequence test fc.constant('ls --verbose'), // long option test fc.constant('ls -l'), // short option test fc.constant('ls -la value'), // option value test fc.constant('ls -abc'), // joined short option test fc.constant('set _flag_completion value'), // complete flag command name test fc.constant('alias la=\'ls -la\''), // alias with name test fc.constant('set var value\necho $var'), // variable expansion with name test ); fc.assert(fc.property(comprehensiveCoverageCode, (fishCode) => { try { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test all newly added functions that were missing coverage expect(() => { for (const node of allNodes.slice(0, 15)) { // Test shebang detection isShebang(node); // Test top level definition detection isTopLevelDefinition(node); // Test conditional clause variations isElseStatement(node); isIfOrElseIfConditional(node); isPossibleUnreachableStatement(node); // Test string character variations isStringCharacter(node); isEmptyString(node); isEndStdinCharacter(node); isEscapeSequence(node); // Test option variations isLongOption(node); isShortOption(node); isOptionValue(node); isJoinedShortOption(node); if (isShortOption(node)) { hasShortOptionCharacter(node, 'a'); } // Test invalid variable name detection isInvalidVariableName(node); // Test variable expansion variations isVariableExpansionWithName(node, 'argv'); isVariableExpansionWithName(node, 'status'); // Test command flag detection isCompleteFlagCommandName(node); // Test parent/sibling finding functions const parent = findParentCommand(node); const prevSibling = findPreviousSibling(node); const parentFunc = findParentFunction(node); const parentVarDef = findParentVariableDefinitionKeyword(node); // Test concatenation detection isConcatenation(node); // Test alias detection isAliasWithName(node, 'la'); // Test for loop variable finding if (isForLoop(node)) { findForLoopVariable(node); } // Test set defined variable finding if (isCommand(node)) { findSetDefinedVariable(node); } // Test parent checking functions hasParent(node, isProgram); const foundParent = findParent(node, isProgram); const parentWithFallback = findParentWithFallback(node, isProgram); // Test function scope functions hasParentFunction(node); const funcScope = findFunctionScope(node); if (allNodes.length > 1) { const otherNode = allNodes[1]!; scopeCheck(node, otherNode); } // Test before command detection isBeforeCommand(node); // Test sibling gathering const siblings = gatherSiblingsTillEol(node); expect(Array.isArray(siblings)).toBe(true); } }).not.toThrow(); return true; } catch (error) { // Allow some failures for edge cases return true; } }), { numRuns: 30 }); }); }); describe('Parsing Module Coverage Tests - src/parsing/', () => { it('should test alias parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.aliasDefinition, (aliasCode) => { try { const testWorkspace = TestWorkspace.createSingle(aliasCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test alias parsing functions expect(() => { for (const node of allNodes) { AliasModule.isAlias(node); isAliasDefinitionName(node); if (AliasModule.isAlias(node)) { AliasModule.getInfo(node); AliasModule.toFunction(node); AliasModule.getNameRange(node); AliasModule.buildDetail(node); } } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test set command parsing functionality', () => { fc.assert(fc.property( fc.oneof(fishCodeGenerators.setCommand, fishCodeGenerators.setWithFlags), (setCode) => { try { const testWorkspace = TestWorkspace.createSingle(setCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test set parsing functions expect(() => { for (const node of allNodes) { SetModule.isSetDefinition(node); SetModule.isSetQueryDefinition(node); SetModule.isSetVariableDefinitionName(node); if (isCommand(node)) { SetModule.findSetChildren(node); SetModule.setModifierDetailDescriptor(node); } } }).not.toThrow(); return true; } catch (error) { return true; } }, ), { numRuns: 20 }); }); it('should test function parsing functionality', () => { fc.assert(fc.property( fc.oneof(fishCodeGenerators.functionDefinition, fishCodeGenerators.functionWithEventHandler), (funcCode) => { try { const testWorkspace = TestWorkspace.createSingle(funcCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const funcNodes = allNodes.filter(node => isFunctionDefinition(node)); // Test function parsing functions expect(() => { for (const node of allNodes) { FunctionModule.isFunctionDefinitionName(node); FunctionModule.isFunctionVariableDefinitionName(node); isFunctionDefinitionName(node); } for (const funcNode of funcNodes) { FunctionModule.findFunctionDefinitionChildren(funcNode); FunctionModule.findFunctionOptionNamedArguments(funcNode); } }).not.toThrow(); return true; } catch (error) { return true; } }, ), { numRuns: 20 }); }); it('should test export command parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.exportCommand, (exportCode) => { try { const testWorkspace = TestWorkspace.createSingle(exportCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test export parsing functions expect(() => { for (const node of allNodes) { ExportModule.isExportDefinition(node); ExportModule.isExportVariableDefinitionName(node); isExportVariableDefinitionName(node); if (isCommand(node)) { ExportModule.findVariableDefinitionNameNode(node); ExportModule.extractExportVariable(node); } } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test argparse parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.argparseWithOptions, (argparseCode) => { try { const testWorkspace = TestWorkspace.createSingle(argparseCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test argparse parsing functions expect(() => { for (const node of allNodes) { isArgparseVariableDefinitionName(node); ArgparseModule.isArgparseVariableDefinitionName(node); ArgparseModule.getArgparseDefinitionName(node); if (isCommand(node)) { ArgparseModule.findArgparseOptions(node); ArgparseModule.findArgparseDefinitionNames(node); ArgparseModule.convertNodeRangeWithPrecedingFlag(node); } } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test completion parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.completeWithOptions, (completeCode) => { try { const testWorkspace = TestWorkspace.createSingle(completeCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test completion parsing functions expect(() => { for (const node of allNodes) { CompleteModule.isCompletionCommandDefinition(node); CompleteModule.isCompletionSymbolShort(node); CompleteModule.isCompletionSymbolLong(node); CompleteModule.isCompletionSymbolOld(node); CompleteModule.isCompletionSymbol(node); if (CompleteModule.isCompletionSymbol(node)) { CompleteModule.getCompletionSymbol(node, doc); } } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test for loop parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.forLoop, (forCode) => { try { const testWorkspace = TestWorkspace.createSingle(forCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test for loop parsing functions expect(() => { for (const node of allNodes) { ForModule.isForVariableDefinitionName(node); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test read command parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.readCommandAdvanced, (readCode) => { try { const testWorkspace = TestWorkspace.createSingle(readCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test read parsing functions expect(() => { for (const node of allNodes) { ReadModule.isReadVariableDefinitionName(node); ReadModule.isReadDefinition(node); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test emit event parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.emitEvent, (emitCode) => { try { const testWorkspace = TestWorkspace.createSingle(emitCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test emit parsing functions expect(() => { for (const node of allNodes) { EmitModule.isEmittedEventDefinitionName(node); EmitModule.isGenericFunctionEventHandlerDefinitionName(node); isEmittedEventDefinitionName(node); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test bind command parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.bindCommand, (bindCode) => { try { const testWorkspace = TestWorkspace.createSingle(bindCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test bind parsing functions expect(() => { for (const node of allNodes) { BindModule.isBindCommand(node); BindModule.isBindKeySequence(node); BindModule.isBindFunctionCall(node); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test source command parsing functionality', () => { fc.assert(fc.property(fishCodeGenerators.sourceCommand, (sourceCode) => { try { const testWorkspace = TestWorkspace.createSingle(sourceCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test source parsing functions expect(() => { for (const node of allNodes) { SourceModule.isSourceCommandName(node); SourceModule.isSourceCommandWithArgument(node); SourceModule.isSourceCommandArgumentName(node); SourceModule.isSourcedFilename(node); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test options parsing functionality with all constructs', () => { const allOptionConstructs = [ fishCodeGenerators.commandWithOptions, fishCodeGenerators.functionWithEventHandler, fishCodeGenerators.completeWithOptions, fishCodeGenerators.setWithFlags, ]; fc.assert(fc.property(fc.oneof(...allOptionConstructs), (optionCode) => { try { const testWorkspace = TestWorkspace.createSingle(optionCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const optionNodes = allNodes.filter(node => isOption(node)); // Test options parsing with actual Options const testOption = OptionsModule.Option.create('-t', '--test').withValue(); const shortOption = OptionsModule.Option.create('-s'); const longOption = OptionsModule.Option.create('--long'); expect(() => { for (const node of optionNodes) { OptionsModule.isMatchingOption(node, testOption); OptionsModule.isMatchingOptionOrOptionValue(node, testOption); OptionsModule.isMatchingOptionValue(node, testOption); OptionsModule.findMatchingOptions(node, testOption, shortOption, longOption); } if (optionNodes.length > 0) { OptionsModule.findOptionsSet(optionNodes, [testOption, shortOption, longOption]); OptionsModule.findOptions(optionNodes, [testOption, shortOption, longOption]); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 20 }); }); it('should test comprehensive parsing modules without errors', () => { fc.assert(fc.property(fishCodeGenerators.fishProgram, (fishCode) => { try { const testWorkspace = TestWorkspace.createSingle(fishCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); // Test all parsing modules safely expect(() => { for (const node of allNodes.slice(0, 10)) { // Test unreachable code detection const unreachableNodes = UnreachableModule.findUnreachableCode(doc.tree.rootNode); expect(Array.isArray(unreachableNodes)).toBe(true); // Test nested string extraction if (isString(node)) { NestedStringsModule.extractCommands(node.text, doc); NestedStringsModule.extractCommandLocations(node.text, node, doc); } // Test all barrel functions isVariableDefinitionName(node); isFunctionDefinitionName(node); isAliasDefinitionName(node); isDefinitionName(node); isExportVariableDefinitionName(node); isArgparseVariableDefinitionName(node); isEmittedEventDefinitionName(node); } }).not.toThrow(); return true; } catch (error) { return true; } }), { numRuns: 25 }); }); }); describe('Performance and Memory Properties', () => { it('should handle large generated Fish programs efficiently', () => { const largeFishProgram = fc.array( fishCodeGenerators.fishProgram, { minLength: 5, maxLength: 20 }, ).map(programs => programs.join('\n\n')); fc.assert(fc.property(largeFishProgram, (largeCode) => { const startTime = Date.now(); const testWorkspace = TestWorkspace.createSingle(largeCode); testWorkspace.initialize(); const doc = testWorkspace.focusedDocument; if (!doc || !doc.tree?.rootNode) return true; const allNodes = getChildNodes(doc.tree.rootNode); const processingTime = Date.now() - startTime; // Property: Processing should complete in reasonable time expect(processingTime).toBeLessThan(5000); // 5 seconds max // Property: Should handle large node counts expect(allNodes.length).toBeGreaterThan(0); // Property: Memory usage should be reasonable (basic smoke test) expect(() => { for (let i = 0; i < Math.min(100, allNodes.length); i++) { getParentNodes(allNodes[i]!); getRange(allNodes[i]!); getNodeText(allNodes[i]!); } }).not.toThrow(); return true; }), { numRuns: 10 }); // Fewer runs for large tests }); }); }); ================================================ FILE: tests/tree-sitter.test.ts ================================================ import * as Parser from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; import { getChildNodes, getNamedChildNodes, findChildNodes, getParentNodes, findFirstParent, getSiblingNodes, findFirstNamedSibling, findFirstSibling, findEnclosingScope, getNodeText, firstAncestorMatch, ancestorMatch, descendantMatch, hasNode, getNamedNeighbors, getRange, findNodeAt, equalRanges, getNodeAt, getNodeAtRange, positionToPoint, pointToPosition, rangeToPoint, isFishExtension, isPositionWithinRange, isPositionAfter, isNodeWithinRange, getLeafNodes, getLastLeafNode, getParentNodesGen, } from '../src/utils/tree-sitter'; import { initializeParser } from '../src/parser'; import * as NodeTypes from '../src/utils/node-types'; function parseString(str: string): Parser.Tree { const tree = parser.parse(str); return tree; } function parseStringForNode(str: string, predicate: (n: SyntaxNode) => boolean) { const tree = parseString(str); const { rootNode } = tree; return getChildNodes(rootNode).filter(predicate); } let parser: Parser; const jestConsole = console; beforeEach(async () => { parser = await initializeParser(); global.console = require('console'); }); afterEach(() => { global.console = jestConsole; if (parser) parser.delete(); }); describe('tree-sitter.ts functions testing', () => { let mockRootNode: SyntaxNode; it('getChildNodes returns all child nodes', () => { mockRootNode = parseString('set -gx a "1" "2" "3"').rootNode; const result = getChildNodes(mockRootNode); expect(result.length).toBe(15); }); it('getNamedChildNodes returns all named child nodes', () => { mockRootNode = parseString('set -gx a "1" "2" "3"').rootNode; const result = getNamedChildNodes(mockRootNode); expect(result.length).toBe(8); expect(result.map(n => n.type)).toEqual([ 'program', 'command', 'word', 'word', 'word', 'double_quote_string', 'double_quote_string', 'double_quote_string', ]); }); it('findChildNodes returns nodes matching predicate', () => { // const predicate = (node: SyntaxNode) => node.type === 'targetType'; mockRootNode = parseString('set -gx a "1" "2" "3"').rootNode; const result = findChildNodes(mockRootNode, NodeTypes.isCommand); expect(result.map(f => f.text)).toEqual(['set -gx a "1" "2" "3"']); const resultName = findChildNodes(mockRootNode, NodeTypes.isCommandName); expect(resultName.map(f => f.text)).toEqual(['set']); }); it('getParentNodes returns all parent nodes', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; const results = getParentNodes(node); expect(results.map(n => n.text)).toEqual(['"3"', 'set -gx a "1" "2" "3"', 'set -gx a "1" "2" "3"']); expect(results.map(n => n.type)).toEqual(['double_quote_string', 'command', 'program']); }); it('findFirstParent returns first parent node matching predicate', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; const result = findFirstParent(node, NodeTypes.isCommand); expect(result?.text).toEqual('set -gx a "1" "2" "3"'); }); it('getSiblingNodes returns sibling nodes', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; const result = getSiblingNodes(node, NodeTypes.isString, 'before'); expect(result.map(t => t.text)).toEqual(['"2"', '"1"']); }); it('findFirstNamedSibling returns first named sibling node', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; const result = findFirstNamedSibling(node, NodeTypes.isVariableDefinitionName)!; expect(result.text).toEqual('a'); }); it('findFirstSibling returns first sibling node', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; const result = findFirstSibling(node, NodeTypes.isOption, 'before')!; expect(result.text).toEqual('-gx'); }); it('findEnclosingScope returns enclosing scope node', () => { const node = parseStringForNode([ 'function __func_1', ' if test -z $argv', ' return 0', ' end', ' set -gx a "1" "2" "3"', 'end', ].join('\n'), (n: SyntaxNode) => n.text === '"3"').pop()!; const result = findEnclosingScope(node); expect(result.type).toEqual('function_definition'); }); it('getNodeText returns text of the node', () => { const input = [ 'function __func_1', ' if test -z $argv', ' return 0', ' end', ' set -gx a "1" "2" "3"', 'end', ].join('\n'); let node = parseStringForNode(input, (n: SyntaxNode) => n.text === '"3"').pop()!; let result = getNodeText(node); expect(result).toEqual('"3"'); node = parseStringForNode(input, (n: SyntaxNode) => n.text === '__func_1').pop()!; result = getNodeText(node); expect(result).toEqual('__func_1'); node = parseStringForNode(input, NodeTypes.isFunctionDefinition).pop()!; result = getNodeText(node); // console.log(result); expect(result).toEqual('__func_1'); }); // test('getNodesTextAsSingleLine returns concatenated text of nodes', () => { // const result = getNodesTextAsSingleLine([mockRootNode]); // // Add assertions here // }); // it('firstAncestorMatch returns first ancestor matching predicate', () => { const input = [ 'function __func_1', ' if test -z $argv', ' return 0', ' end', ' set -gx a "1" "2" "3"', 'end', ].join('\n'); const node = parseStringForNode(input, (n: SyntaxNode) => n.text === '"3"').pop()!; const result = firstAncestorMatch(node, NodeTypes.isCommand)!; expect(result.text).toEqual('set -gx a "1" "2" "3"'); }); it('ancestorMatch returns all matching ancestor nodes', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; const result = ancestorMatch(node, NodeTypes.isOption, false); expect(result.map(n => n.text)).toEqual([ '-gx', '-gx', ]); }); it('descendantMatch returns all matching descendant nodes', () => { const node = parseStringForNode('set -gx a "1" "2" "3"', NodeTypes.isCommand).pop()!; const result = descendantMatch(node, NodeTypes.isVariableDefinitionName); expect(result.map(n => n.text)).toEqual(['a']); }); it('hasNode checks if array has the node', () => { const root = parseString('set -gx a "1" "2" "3"').rootNode; const node = getChildNodes(root).find(n => NodeTypes.isOption(n))!; // const node = parseStringForNode('set -gx a "1" "2" "3"', NodeTypes.isCommand).pop()! const result = hasNode(getChildNodes(root), node); expect(result).toBeTruthy(); }); it('getNamedNeighbors returns named neighbors', () => { const root = parseString('set -gx a "1" "2" "3"').rootNode; const node = getChildNodes(root).find(n => NodeTypes.isOption(n))!; const result = getNamedNeighbors(node); expect(result.map(n => n.text)).toEqual(['set', '-gx', 'a', '"1"', '"2"', '"3"']); }); it('getRange returns range of the node', () => { const root = parseString('set -gx a "1" "2" "3"').rootNode; const node = getChildNodes(root).find(n => NodeTypes.isOption(n))!; expect(getRange(root)).toEqual({ start: { line: 0, character: 0 }, end: { line: 0, character: 21 } }); expect(getRange(node)).toEqual({ start: { line: 0, character: 4 }, end: { line: 0, character: 7 } }); }); it('findNodeAt finds node at position', () => { const tree = parseString('set -gx a "1" "2" "3"'); const result = findNodeAt(tree, 0, 5)!; expect(result.text).toEqual('-gx'); }); // it('equalRanges checks if ranges are equal', () => { const tree = parseString('set -gx a "1" "2" "3"'); const rootNode = tree!.rootNode; const rangeA = { start: { line: 0, character: 0 }, end: { line: 0, character: 21 } }; const rangeB = getRange(rootNode); const result = equalRanges(rangeA, rangeB); expect(result).toBeTruthy(); }); it('getNodeAt finds node at position', () => { const tree = parseString('set -gx a "1" "2" "3"'); const result = getNodeAt(tree, 0, 0)!; expect(result.text).toBe('set'); }); it('getNodeAtRange finds node at range', () => { const tree = parseString('set -gx a "1" "2" "3"'); const rootNode = tree!.rootNode; const range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; const result = getNodeAtRange(rootNode, range)!; expect(result.text).toBe('set'); // console.log(result.text); }); it('positionToPoint converts position to point', () => { const position = { line: 0, character: 5 }; const start = positionToPoint(position); const end = positionToPoint(position); expect(positionToPoint(position)).toEqual({ row: 0, column: 5, }); }); it('pointToPosition converts point to position', () => { const point = { row: 0, column: 1 }; const result = pointToPosition(point); expect(result).toEqual({ line: 0, character: 1, }); }); it('rangeToPoint converts range to point', () => { const range = { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }; const result = rangeToPoint(range); expect(result).toEqual({ row: 0, column: 0, }); }); // it('getRangeWithPrecedingComments returns range with preceding comments', () => { // const result = getRangeWithPrecedingComments(mockRootNode); // // Add assertions here // }); // // it('getPrecedingComments returns preceding comments', () => { // const result = getPrecedingComments(mockRootNode); // // Add assertions here // }); // it('isFishExtension checks if path has fish extension', () => { const result = isFishExtension('file:///home/user/.config/fish/functions/test.fish'); expect(result).toBeTruthy(); }); it('isPositionWithinRange checks if position is within range', () => { const tree = parseString('set -gx a "1" "2" "3"'); const rootNode = tree!.rootNode; const position = { line: 0, character: 0 }; const range = getRange(rootNode); const result = isPositionWithinRange(position, range); expect(result).toBeTruthy(); }); it('isPositionAfter checks if position is after another position', () => { const positionA = { line: 0, character: 0 }; const positionB = { line: 0, character: 5 }; const result = isPositionAfter(positionA, positionB); expect(result).toBeTruthy(); }); it('isNodeWithinRange checks if node is within range', () => { const tree = parseString('set -gx a "1" "2" "3"'); const rootNode = tree!.rootNode; const range = { start: { line: 0, character: 0 }, end: { line: 0, character: 21 } }; const result = isNodeWithinRange(rootNode.firstNamedChild!, range); expect(result).toBeTruthy(); }); it('getLeafNodes returns leaf nodes', () => { const tree = parseString('set -gx a "1" "2" "3"'); const rootNode = tree!.rootNode; const result = getLeafNodes(rootNode); expect(result.map(m => m.text)).toEqual([ 'set', '-gx', 'a', '"', '"', '"', '"', '"', '"', ]); }); it('getLastLeaf returns last leaf node', () => { const tree = parseString('set -gx a "1" "2" "3"'); const rootNode = tree!.rootNode; const result = getLastLeafNode(rootNode); expect(result.text).toEqual('"'); }); it('getParentsNodesGen*', () => { const { rootNode } = parseString('set -gx a "1" "2" "3"'); const node = getChildNodes(rootNode).find(n => n.text === 'a')!; const withoutSelf: SyntaxNode[] = []; for (const parent of getParentNodesGen(node)) { withoutSelf.push(parent); } expect(withoutSelf.length).toBe(2); expect(withoutSelf.map(n => n.type)).toEqual(['command', 'program']); const withSelf: SyntaxNode[] = []; for (const parent of getParentNodesGen(node, true)) { withSelf.push(parent); } expect(withSelf.length).toBe(3); expect(withSelf.map(n => n.type)).toEqual(['word', 'command', 'program']); }); // it('matchesTypes', () => { // const tree = parseString('set -gx a "1" "2" "3"'); // const rootNode = tree!.rootNode; // getChildNodes(rootNode).forEach((child) => { // console.log(child.grammarType, child.grammarType); // }) // // }); // it('matchesArgument checks if node matches argument', () => { // const result = matchesArgument(mockRootNode, 'arg'); // // Add assertions here // }); // // it('getCommandArgumentValue returns command argument value', () => { // const result = getCommandArgumentValue(mockRootNode, 'arg'); // // Add assertions here // }); }); ================================================ FILE: tests/unreachable.test.ts ================================================ import { analyzer, Analyzer } from '../src/analyze'; import { ErrorCodes } from '../src/diagnostics/error-codes'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { createFakeLspDocument, setLogger } from './helpers'; describe('Comprehensive Unreachable Code Detection [NEW]', () => { setLogger(); beforeEach(async () => { await Analyzer.initialize(); }); // Basic cases from CLAUDE.md examples describe('Basic unreachable code detection', () => { it('should detect simple if/else with returns', async () => { const fishCode = ` if true return 0 else return 1 end echo "This is unreachable"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect switch/case with all paths exiting', async () => { const fishCode = ` switch $var case 'Y' 'y' '' return 0 case '*' return 1 end echo "This is unreachable"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect conditional execution with both branches exiting', async () => { const fishCode = ` echo a and return 0 or return 1 echo "This is unreachable"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect unreachable code in function', async () => { const fishCode = ` function test_unreachable if true return 0 else return 1 end echo "This is unreachable" end test_unreachable`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect unreachable code after exit', async () => { const fishCode = ` command -aq nvim and exit 0 or exit 1 echo "This is unreachable"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); }); // The main issue: nested blocks describe('Nested block handling (main bug)', () => { it('should correctly handle nested if/else - case where inner branch does not terminate all paths', async () => { const fishCode = ` if status is-interactive if true return 0 else return 1 end echo "This is unreachable" else echo "This is reachable" end echo "This is also reachable"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should detect the unreachable echo inside the first if branch expect(unreachableDiagnostics).toHaveLength(1); expect(unreachableDiagnostics[0]!.message.toLowerCase()).toContain('unreachable'); }); it('should NOT flag reachable code - case from GitHub issue', async () => { const fishCode = ` function reachable_test set -l cond1 0 set -l cond2 1 if test $cond1 -eq 0 if test $cond2 -eq 0 return 1 else # Do some stuff... # No function exit; function execution will continue after parent if block. end else return 1 end echo reachable end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should NOT detect any unreachable code - the final echo is reachable expect(unreachableDiagnostics).toHaveLength(0); }); it('should handle deeply nested structures correctly', async () => { const fishCode = ` function deep_nesting if test -n "$var1" if test -n "$var2" if test -n "$var3" return 0 else return 1 end echo "unreachable in nested if" else echo "reachable in middle else" end echo "reachable after nested if" else echo "reachable in outer else" end echo "reachable at end" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should only detect the unreachable echo inside the innermost if expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle nested structures with mixed control flow', async () => { const fishCode = ` function mixed_control_flow if test -n "$condition" switch $action case 'exit' return 0 case 'continue' return 1 case '*' return 2 end echo "unreachable after complete switch" else echo "reachable in else branch" end echo "reachable at end" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should detect unreachable code after the complete switch inside the if expect(unreachableDiagnostics).toHaveLength(1); }); }); // Edge cases and complex scenarios describe('Edge cases and advanced scenarios', () => { it('should handle multiple levels of nesting with partial termination', async () => { const fishCode = ` function complex_nesting if test -n "$outer" if test -n "$inner1" return 0 end if test -n "$inner2" return 1 else return 2 end echo "unreachable after second nested if" end echo "reachable - outer if has no else" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should detect unreachable code after the second nested if/else expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle loops with terminal statements', async () => { const fishCode = ` function loop_with_terminals for item in $list if test "$item" = "special" return 0 else return 1 end echo "unreachable in loop iteration" end echo "reachable after loop" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should detect unreachable code inside the loop after the complete if/else expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle nested conditional execution', async () => { const fishCode = ` function nested_conditional if test -n "$var" echo "checking" and return 0 or return 1 echo "unreachable after conditional execution" end echo "reachable" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); }); // Negative test cases - should NOT detect unreachable code describe('Negative cases - reachable code', () => { it('should NOT detect unreachable code when if has no else', async () => { const fishCode = ` if test -n "$var" return 0 end echo "reachable - no else clause"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should NOT detect unreachable code when switch has no default case', async () => { const fishCode = ` switch $var case 'a' return 0 case 'b' return 1 end echo "reachable - no default case"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should NOT detect unreachable code with incomplete conditional execution', async () => { const fishCode = ` echo "test" && return 0 echo "reachable - no or clause"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should NOT detect unreachable code in nested structure with incomplete paths', async () => { const fishCode = ` function incomplete_paths if test -n "$outer" if test -n "$inner" return 0 end echo "reachable - inner if has no else" end echo "reachable - outer if has no else" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); }); // Test console logging for tree structure analysis describe('Parser tree structure analysis', () => { it('should log syntax tree for debugging nested structures', async () => { const fishCode = ` function debug_structure if status is-interactive if true return 0 else return 1 end echo "This should be unreachable" else echo "This is reachable" end echo "This is also reachable" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); // Log the syntax tree structure for analysis console.log('=== SYNTAX TREE STRUCTURE ==='); console.log('Root type:', root!.type); console.log('Root text preview:', root!.text.substring(0, 100) + '...'); function logNode(node: any, indent = 0) { const prefix = ' '.repeat(indent); console.log(`${prefix}${node.type} [${node.startPosition.row}:${node.startPosition.column}-${node.endPosition.row}:${node.endPosition.column}]`); if (node.text.length < 50) { console.log(`${prefix} text: "${node.text}"`); } for (const child of node.namedChildren) { logNode(child, indent + 1); } } // Find the function definition and log its structure for (const child of root!.namedChildren) { if (child.type === 'function_definition') { console.log('=== FUNCTION STRUCTURE ==='); logNode(child); break; } } const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); console.log('=== DIAGNOSTICS ==='); console.log(`Found ${unreachableDiagnostics.length} unreachable diagnostics`); unreachableDiagnostics.forEach((diag, i) => { console.log(`${i + 1}. Line ${diag.range.start.line}: ${diag.message}`); }); expect(unreachableDiagnostics).toHaveLength(1); }); }); // https://github.com/ndonfris/fish-lsp/issues/105 describe('gh issue #105', () => { it('should not detect unreachable code in nested ifs with returns', async () => { const fishCode = ` function reachable_test set -l cond1 0 set -l cond2 1 if test $cond1 -eq 0 if test $cond2 -eq 0 return 1 else # Do some stuff... # No function exit; function execution will continue after parent \`if\` block. end else return 1 end echo reachable end `; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should NOT detect any unreachable code - the final echo is reachable expect(unreachableDiagnostics).toHaveLength(0); }); }); // Extended tests for comprehensive coverage describe('Terminal statement variations', () => { it('should detect unreachable code after break statement', async () => { const fishCode = ` for i in (seq 5) if test $i -eq 3 break echo "unreachable after break" end echo "reachable in loop" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect unreachable code after continue statement', async () => { const fishCode = ` for i in (seq 5) if test $i -eq 3 continue echo "unreachable after continue" end echo "reachable in loop" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect unreachable code after exit statement in function', async () => { const fishCode = ` function test_exit exit 1 echo "unreachable after exit" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); }); describe('Switch statement edge cases', () => { it('should detect unreachable code with single-quoted wildcard patterns', async () => { const fishCode = ` switch $var case 'option1' return 0 case 'option2' return 1 case '*' return 2 end echo "unreachable after complete switch with quoted wildcard"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should NOT detect unreachable code with incomplete switch patterns', async () => { const fishCode = ` switch $var case 'a' 'b' return 0 case 'c' return 1 end echo "reachable - no default case covers all possibilities"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should handle switch cases with nested control flow', async () => { const fishCode = ` function complex_switch switch $argv[1] case 'nested' if test -n "$argv[2]" return 0 else return 1 end echo "unreachable after nested if/else in case" case '*' return 99 end echo "unreachable after complete switch with nested structures" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should detect at least the unreachable statements expect(unreachableDiagnostics.length).toBeGreaterThanOrEqual(1); }); }); describe('Complex conditional execution patterns', () => { it('should handle partial conditional execution chains', async () => { const fishCode = ` function partial_conditional command -v git and echo "git found" and return 0 # Missing 'or' branch - execution can continue echo "reachable - incomplete conditional chain" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should handle mixed conditional execution and control structures', async () => { const fishCode = ` function mixed_patterns if test -n "$HOME" command -v bash and return 0 or echo "bash not found" echo "reachable after incomplete and/or in if" else return 1 end echo "reachable after if with mixed patterns" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); }); describe('More deeply nested control structures', () => { it('should handle triple-nested if statements', async () => { const fishCode = ` function triple_nested if test -n "$var1" if test -n "$var2" if test -n "$var3" return 0 else return 1 end echo "unreachable after innermost if/else" else echo "reachable in middle else" end echo "reachable after middle if" else echo "reachable in outer else" end echo "reachable at end" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle nested switches inside if statements', async () => { const fishCode = ` function nested_switch_in_if if test -n "$mode" switch $mode case 'dev' return 0 case 'prod' return 1 case '*' return 2 end echo "unreachable after complete nested switch" else echo "reachable in else" end echo "reachable at end" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle nested if statements inside switch cases', async () => { const fishCode = ` function nested_if_in_switch switch $action case 'check' if test -f "$file" return 0 else return 1 end echo "unreachable after nested if in switch case" case '*' return 3 end echo "unreachable after complete switch with nested if" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); }); describe('Loop-specific scenarios', () => { it('should handle unreachable code in for loops with complete if/else', async () => { const fishCode = ` function loop_with_complete_if for item in $items if test "$item" = "target" break else continue end echo "unreachable in loop - all if paths exit" end echo "reachable after loop" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle nested loops with terminal statements', async () => { const fishCode = ` function nested_loops for outer in (seq 3) for inner in (seq 3) if test $outer -eq $inner return 0 end end echo "reachable after inner loop" end echo "reachable after outer loop" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Inner loop doesn't have complete coverage, so code after should be reachable expect(unreachableDiagnostics).toHaveLength(0); }); it('should handle for loops with command substitution iterables', async () => { const fishCode = ` function loop_with_substitution for file in (find . -name "*.fish") if test -r "$file" return 0 else return 1 end echo "unreachable after if/else in loop" end echo "reachable after loop" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); }); describe('Comment handling', () => { it('should allow comments after terminal statements', async () => { const fishCode = ` function with_comments return 0 # This comment should be allowed # Multiple comments are OK echo "but this code is unreachable" # Comments after unreachable code end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // Should only flag the echo statement, not the comments expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle inline comments properly', async () => { const fishCode = ` function with_inline_comments if test -n "$var" # check if var is set return 0 # early return else return 1 # alternative return end echo "unreachable" # this should be flagged end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); }); describe('Edge cases and error handling', () => { it('should handle empty if statements', async () => { const fishCode = ` function empty_if if test -n "$var" # empty body end echo "reachable after empty if" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should handle empty switch statements', async () => { const fishCode = ` function empty_switch switch $var case '*' # empty case end echo "reachable after empty switch" end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); }); describe('Function-level vs top-level analysis', () => { it('should detect unreachable code at top level', async () => { const fishCode = ` if test -n "$SHELL" exit 0 else exit 1 end echo "unreachable at top level"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should handle mixed function and top-level unreachable code', async () => { const fishCode = ` # Top-level unreachable code return 0 echo "unreachable at top level" function test_func exit 1 echo "unreachable in function" end # More top-level code that's reachable echo "this is reachable"`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics.length).toBeGreaterThanOrEqual(2); }); }); }); describe('Unreachable Code Detection [LEGACY]', () => { setLogger(); beforeEach(async () => { await Analyzer.initialize(); }); it('should detect code after return statement', async () => { const fishCode = ` function test_func return 0 echo "unreachable" set var "also unreachable" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(2); }); it('should detect code after exit statement', async () => { const fishCode = ` function test_func exit 1 echo "this will never run" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect code after complete if-else with returns', async () => { const fishCode = ` function test_func if test $argv[1] = "yes" return 0 else return 1 end echo "unreachable after complete if-else" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should NOT detect code after incomplete if statement', async () => { const fishCode = ` function test_func if test $argv[1] = "yes" return 0 end echo "reachable - no else clause" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should detect code after switch with default case', async () => { const fishCode = ` function test_func switch $argv[1] case "a" return 1 case "b" return 2 case "*" return 0 end echo "unreachable after complete switch" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should NOT detect code after incomplete switch', async () => { const fishCode = ` function test_func switch $argv[1] case "a" return 1 case "b" return 2 end echo "reachable - no default case" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should allow comments after terminal statements', async () => { const fishCode = ` function test_func return 0 # This comment should be allowed echo "but this is unreachable" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); // Only the echo statement }); it('should handle break and continue in loops', async () => { const fishCode = `function test_func for i in (seq 10) if test "$i" = "5" break echo "unreachable after break" end if test "$i" = "3" continue echo "unreachable after continue" end echo "this is reachable" end end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(2); // after break and after continue }); it('should detect code after switch with default case 2', async () => { const fishCode = ` function test_func switch $argv[1] case "a" return 1 case "b" return 2 case \\* return 0 end echo "unreachable after complete switch" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); console.log(fishCode); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); }); it('should detect code after conditional execution with and/or', async () => { const fishCode = `function asdf set -q PATH and return 1 or return 0 echo hi # unreachable end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); // Should detect the echo statement }); it('should NOT detect unreachable code after incomplete conditional execution', async () => { const fishCode = `function test_func set -q PATH and return 1 # no 'or' clause - execution can continue echo "this is reachable" end`; const fakeDoc = createFakeLspDocument('config.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(0); }); it('should NOT mark code unreachable after single || return (user reported bug)', async () => { const fishCode = `function git_branch_exists --description 'takes array of branch names, prints first one that exists' argparse --ignore-unknown fallback= -- $argv or return # Skip if not in a git directory git rev-parse --git-dir &>/dev/null || return for branch in $argv # should NOT be marked unreachable if git rev-parse --verify $branch &>/dev/null echo $branch return end end # none of the branches found existed, so echo the fallback if set -lq _flag_fallback echo $_flag_fallback return end return 1 end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); // The 'for branch in $argv' line should NOT be marked as unreachable // because only ONE path (failure) exits via || return expect(unreachableDiagnostics).toHaveLength(0); }); it('SHOULD mark code unreachable after complete and/or chain', async () => { const fishCode = `function test_both_paths_exit git rev-parse --git-dir &>/dev/null and return 0 or return 1 echo "This IS unreachable" # Both success AND failure paths exit end`; const fakeDoc = createFakeLspDocument('test.fish', fishCode); const { root } = analyzer.analyze(fakeDoc); const diagnostics = await getDiagnosticsAsync(root!, fakeDoc); const unreachableDiagnostics = diagnostics.filter(d => d.code === ErrorCodes.unreachableCode); expect(unreachableDiagnostics).toHaveLength(1); expect(unreachableDiagnostics[0]?.range.start.line).toBe(4); // The echo line }); }); ================================================ FILE: tests/virtual-file-handling.test.ts ================================================ import { setLogger, setupStartupMock, createMockConnection } from './helpers'; import { AnalyzedDocument, analyzer, Analyzer } from '../src/analyze'; import { documents, LspDocument } from '../src/document'; import { workspaceManager } from '../src/utils/workspace-manager'; import { initializeParser } from '../src/parser'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import FishServer from '../src/server'; import { Config } from '../src/config'; import * as LSP from 'vscode-languageserver'; import { Workspace } from '../src/utils/workspace'; import { getDiagnosticsAsync } from '../src/diagnostics/validate'; import { testChangeDocument, testClearDocuments, testOpenDocument } from './document-test-helpers'; const testDiagnosticsWrapper = async (analyzedDoc: AnalyzedDocument) => { // analyzer.diagnostics.requestUpdate(analyzedDoc.documnt.uri, true); const abortSig = new AbortController(); const diags = await getDiagnosticsAsync(analyzedDoc.root!, analyzedDoc.document, abortSig.signal, 10); analyzer.diagnostics.setForTesting(analyzedDoc.document.uri, diags); return analyzer.diagnostics.get(analyzedDoc.document.uri); }; // Mock the startup module at the top level setupStartupMock(); vi.mock('../src/utils/startup', () => ({ connection: createMockConnection(), setExternalConnection: vi.fn(), })); describe('Virtual Fish File Handling', () => { let mockConnection: LSP.Connection; beforeAll(async () => { setLogger(); await initializeParser(); await setupProcessEnvExecFile(); }); beforeEach(async () => { // Reset mocks vi.clearAllMocks(); // Setup mock connection mockConnection = createMockConnection(); testClearDocuments(); workspaceManager.clear(); await Analyzer.initialize(); }); afterEach(() => { vi.clearAllMocks(); testClearDocuments(); workspaceManager.clear(); }); describe('Virtual URI Schemes', () => { it('should handle https://file.fish URIs', async () => { const virtualUri = 'https://example.com/virtual.fish'; const fishCode = ` function hello_world echo "Hello from virtual file!" end `.trim(); // Test that we can create a document with a virtual URI const doc = LspDocument.createTextDocumentItem(virtualUri, fishCode); expect(doc).toBeDefined(); expect(doc.uri).toBe(virtualUri); expect(doc.getText()).toBe(fishCode); // Add to documents collection testOpenDocument(doc); const retrievedDoc = documents.get(virtualUri); expect(retrievedDoc).toBeDefined(); expect(retrievedDoc?.uri).toBe(virtualUri); }); it('should handle data: URIs for fish content', async () => { const fishCode = 'function test\n echo "test"\nend'; const dataUri = `data:text/fish;base64,${Buffer.from(fishCode).toString('base64')}`; // Simulate creating document from data URI const doc = LspDocument.createTextDocumentItem(dataUri, fishCode); expect(doc.uri).toBe(dataUri); expect(doc.getText()).toBe(fishCode); // Test analysis works on virtual content const analyzedDoc = analyzer.analyze(doc); expect(analyzedDoc).toBeDefined(); expect(analyzedDoc.document.uri).toBe(dataUri); }); it('should handle untitled: URIs for temporary fish files', async () => { const untitledUri = 'untitled:Untitled-1.fish'; const fishCode = ` set -l var_name "value" echo $var_name `.trim(); const doc = LspDocument.createTextDocumentItem(untitledUri, fishCode); expect(doc.uri).toBe(untitledUri); // Test that symbols can be extracted from virtual content const analyzedDoc = analyzer.analyze(doc); const symbols = analyzer.getDocumentSymbols(doc.uri); expect(symbols).toBeDefined(); expect(symbols.length).toBeGreaterThan(0); }); }); describe('Server Virtual File Support', () => { it('should create web server that handles virtual files', async () => { const virtualParams: LSP.InitializeParams = { processId: null, rootUri: null, rootPath: null, capabilities: { textDocument: { completion: { completionItem: { snippetSupport: true } }, hover: { contentFormat: ['markdown', 'plaintext'] }, }, workspace: { workspaceFolders: true }, }, initializationOptions: {}, workspaceFolders: null, }; Config.isWebServer = true; const { server, initializeResult } = await FishServer.create(mockConnection, virtualParams); expect(server).toBeDefined(); expect(initializeResult).toBeDefined(); expect(initializeResult.capabilities).toBeDefined(); expect(initializeResult.capabilities.textDocumentSync).toBeDefined(); expect(initializeResult.capabilities.completionProvider).toBeDefined(); expect(initializeResult.capabilities.hoverProvider).toBeDefined(); }); // it('should handle didOpenTextDocument with virtual URI', async () => { // const { server } = await FishServer.createWebServer({ // connection: mockConnection, // params: { // processId: null, // rootUri: null, // rootPath: null, // capabilities: {}, // initializationOptions: {}, // workspaceFolders: null, // }, // }); // // const virtualUri = 'https://example.com/test.fish'; // const fishContent = 'function virtual_func\n echo "hello"\nend'; // // const openParams: LSP.DidOpenTextDocumentParams = { // textDocument: { // uri: virtualUri, // languageId: 'fish', // version: 1, // text: fishContent, // }, // }; // // // This should not throw and should handle the virtual file // await expect(server.didOpenTextDocument(openParams)).resolves.not.toThrow(); // // // Verify document was added // const doc = documents.getDocument(virtualUri); // expect(doc).toBeDefined(); // expect(doc?.getText()).toBe(fishContent); // }); it('should provide completions for virtual files', async () => { Config.isWebServer = true; const { server } = await FishServer.create(mockConnection, { processId: 0, rootUri: null, rootPath: null, capabilities: {}, initializationOptions: {}, workspaceFolders: [] } as LSP.InitializeParams); const virtualUri = 'memory://test.fish'; const fishContent = ` function my_function echo "test" end # Complete here: my_f `.trim(); // Open virtual document testOpenDocument( LspDocument.createTextDocumentItem( virtualUri, fishContent, ), ); // documents.onDidOpen( // LspDocument.createTextDocumentItem( // uri: virtualUri, // text: fishContent, // ) // ); // await server.didOpenTextDocument(); // Request completions at the end of the file const completionParams: LSP.CompletionParams = { textDocument: { uri: virtualUri }, position: { line: 4, character: 4 }, // After "my_f" }; const completions = await server.onCompletion(completionParams); expect(completions).toBeDefined(); // Should have some completions available (might be empty due to lack of background analysis) expect(completions.items).toBeDefined(); }); it('should handle hover for virtual files', async () => { Config.isWebServer = true; const { server } = await FishServer.create(mockConnection, { processId: 0, rootUri: null, rootPath: null, capabilities: {}, initializationOptions: {}, workspaceFolders: [] } as LSP.InitializeParams); const virtualUri = 'vscode-vfs://github/user/repo/test.fish'; const fishContent = ` function test_func echo "Testing hover" end test_func `.trim(); // Open virtual document testOpenDocument( LspDocument.createTextDocumentItem( virtualUri, fishContent, ), ); // await server.didOpenTextDocument({ // textDocument: { // uri: virtualUri, // languageId: 'fish', // version: 1, // text: fishContent, // }, // }); // Request hover on function call const hoverParams: LSP.HoverParams = { textDocument: { uri: virtualUri }, position: { line: 4, character: 2 }, // On "test_func" }; const hover = await server.onHover(hoverParams); // Hover might be null if symbol isn't found, but shouldn't throw expect(hover).toBeDefined(); }); it('should update virtual document content when client sends didChangeTextDocument', async () => { Config.isWebServer = true; const { server } = await FishServer.create(mockConnection, { processId: 0, rootUri: null, rootPath: null, capabilities: {}, initializationOptions: {}, workspaceFolders: [] } as LSP.InitializeParams); const virtualUri = 'https://example.com/dynamic.fish'; const initialContent = ` function original_func echo "original content" end `.trim(); // Open initial virtual document testOpenDocument( LspDocument.createTextDocumentItem( virtualUri, initialContent, ), ); // await server.didOpenTextDocument({ // textDocument: { // uri: virtualUri, // languageId: 'fish', // version: 1, // text: initialContent, // }, // }); // Verify initial document exists with correct content const initialDoc = documents.get(virtualUri); expect(initialDoc).toBeDefined(); expect(initialDoc?.getText()).toBe(initialContent); expect(initialDoc?.version).toBe(1); // Send didChangeTextDocument to update the virtual document const updatedContent = ` function updated_func echo "updated content" set -l new_var "added variable" end function additional_func echo "new function added" end `.trim(); const changeParams: LSP.DidChangeTextDocumentParams = { textDocument: { uri: virtualUri, version: 2, }, contentChanges: [ { // Full document replacement text: updatedContent, }, ], }; // Apply the changes testChangeDocument(changeParams.textDocument.uri, updatedContent, changeParams.textDocument.version); // await server.didChangeTextDocument(changeParams); // Verify document was updated with new content const updatedDoc = documents.get(virtualUri); expect(updatedDoc).toBeDefined(); expect(updatedDoc?.getText()).toBe(updatedContent); expect(updatedDoc?.version).toBe(2); expect(updatedDoc?.uri).toBe(virtualUri); // Verify the server can still provide language features on the updated content const symbols = await server.onDocumentSymbols({ textDocument: { uri: virtualUri }, }); expect(symbols).toBeDefined(); expect(symbols.length).toBeGreaterThanOrEqual(2); expect(symbols.some((s: any) => s.name === 'updated_func')).toBe(true); expect(symbols.some((s: any) => s.name === 'additional_func')).toBe(true); expect(symbols.some((s: any) => s.name === 'original_func')).toBe(false); // Test incremental changes const incrementalChangeParams: LSP.DidChangeTextDocumentParams = { textDocument: { uri: virtualUri, version: 3, }, contentChanges: [ { range: { start: { line: 2, character: 4 }, end: { line: 2, character: 21 }, }, text: 'echo "incrementally updated"', }, ], }; // Apply incremental change testChangeDocument( incrementalChangeParams.textDocument.uri, updatedContent.replace('echo "updated content"', 'echo "incrementally updated"'), incrementalChangeParams.textDocument.version, ); // // await server.didChangeTextDocument(incrementalChangeParams); const finalDoc = documents.get(virtualUri); expect(finalDoc).toBeDefined(); expect(finalDoc?.version).toBe(3); expect(finalDoc?.getText()).toContain('incrementally updated'); }); }); describe('File System Independence', () => { it('should work without physical file system access', async () => { // Mock file system operations to simulate no file access const originalRead = require('fs').readFileSync; vi.spyOn(require('fs'), 'readFileSync').mockImplementation(() => { throw new Error('ENOENT: no such file or directory'); }); try { const virtualUri = 'memory://test.fish'; const content = 'echo "hello world"'; const doc = LspDocument.createTextDocumentItem(virtualUri, content); const analyzed = analyzer.analyze(doc); expect(analyzed).toBeDefined(); expect(analyzed.document.getText()).toBe(content); // analyzer.diagnostics.requestUpdate(virtualUri, true); // Should be able to get diagnostics even without file system const diagnostics = await testDiagnosticsWrapper(analyzed); expect(diagnostics).toBeDefined(); expect(Array.isArray(diagnostics)).toBe(true); } finally { vi.restoreAllMocks(); } }); it('should handle WebSocket-like URIs', async () => { const wsUri = 'ws://localhost:8080/fish-lsp'; const content = ` set -l greeting "Hello from WebSocket!" echo $greeting `.trim(); const doc = LspDocument.createTextDocumentItem(wsUri, content); expect(doc.uri).toBe(wsUri); // Should analyze without issues const analyzed = analyzer.analyze(doc); expect(analyzed.document.uri).toBe(wsUri); // Should find symbols const symbols = analyzer.getDocumentSymbols(wsUri); expect(symbols).toBeDefined(); }); }); describe('Docker Container Environment Simulation', () => { it('should work in containerized environment with no fish binary', async () => { // Mock exec operations that would normally call fish const mockExec = vi.fn().mockRejectedValue(new Error('fish: command not found')); vi.doMock('child_process', () => ({ execFile: mockExec, execFileSync: mockExec, exec: mockExec, execSync: mockExec, })); const virtualUri = 'container://fish/test.fish'; const content = ` function container_func set -l container_var "running in container" echo $container_var end `.trim(); const doc = LspDocument.createTextDocumentItem(virtualUri, content); // Should still be able to analyze syntax const analyzed = analyzer.analyze(doc); expect(analyzed).toBeDefined(); // Should extract function definitions const symbols = analyzer.getDocumentSymbols(virtualUri); expect(symbols).toBeDefined(); expect(symbols.some(s => s.name === 'container_func')).toBe(true); }); it('should provide basic language features without shell access', async () => { Config.isWebServer = true; const { server } = await FishServer.create(mockConnection, { processId: 0, rootUri: null, rootPath: null, capabilities: {}, initializationOptions: {}, workspaceFolders: [] } as LSP.InitializeParams); const dockerUri = 'docker://container/workspace/script.fish'; const fishScript = ` #!/usr/bin/fish function deploy_app set -l app_name $argv[1] echo "Deploying $app_name" if test -z "$app_name" echo "Error: App name required" return 1 end echo "Deployment complete" end deploy_app myapp `.trim(); // Open file in virtual Docker environment testOpenDocument( LspDocument.createTextDocumentItem( dockerUri, fishScript, ), ); // await server.didOpenTextDocument({ // textDocument: { // uri: dockerUri, // languageId: 'fish', // version: 1, // text: fishScript, // }, // }); // Should provide document symbols const symbols = await server.onDocumentSymbols({ textDocument: { uri: dockerUri }, }); expect(symbols).toBeDefined(); expect(symbols.length).toBeGreaterThan(0); expect(symbols.some((s: any) => s.name === 'deploy_app')).toBe(true); // Should provide formatting const formatting = await server.onDocumentFormatting({ textDocument: { uri: dockerUri }, options: { tabSize: 4, insertSpaces: true, }, }); expect(formatting).toBeDefined(); expect(Array.isArray(formatting)).toBe(true); }); }); describe('URI Scheme Edge Cases', () => { it('should handle URIs with query parameters', () => { const uriWithQuery = 'https://example.com/test.fish?version=1&temp=true'; const content = 'echo "query test"'; const doc = LspDocument.createTextDocumentItem(uriWithQuery, content); expect(doc.uri).toBe(uriWithQuery); expect(doc.getText()).toBe(content); }); it('should handle URIs with fragments', () => { const uriWithFragment = 'vscode://file/test.fish#line42'; const content = 'echo "fragment test"'; const doc = LspDocument.createTextDocumentItem(uriWithFragment, content); expect(doc.uri).toBe(uriWithFragment); }); it('should handle custom protocol URIs', () => { const customUri = 'fish-lsp://virtual/remote-file.fish'; const content = ` function remote_function echo "This function exists only in memory" end `.trim(); const doc = LspDocument.createTextDocumentItem(customUri, content); const analyzed = analyzer.analyze(doc); expect(analyzed.document.uri).toBe(customUri); const symbols = analyzer.getDocumentSymbols(customUri); expect(symbols.some(s => s.name === 'remote_function')).toBe(true); }); }); describe('Virtual Document Analysis and Diagnostics', () => { it('should start analysis on virtual document and provide diagnostics', async () => { const virtualUri = 'virtual://memory/test-analysis.fish'; const fishContentWithErrors = ` function test_func echo "missing end statement" set $invalid_var "should trigger diagnostic for dollar sign in variable name" if test -n $unclosed_test echo "unclosed if statement" `.trim(); // Create virtual document const virtualDoc = LspDocument.createTextDocumentItem(virtualUri, fishContentWithErrors); testOpenDocument(virtualDoc); const workspace = await Workspace.create('virtual-workspace', virtualDoc.uri, virtualDoc.uri)!; workspaceManager.handleOpenDocument(virtualDoc); workspaceManager.handleUpdateDocument(virtualDoc); workspaceManager.setCurrent(workspace); workspaceManager.handleOpenDocument(virtualDoc); workspace.addDocument(virtualDoc); analyzer.analyze(virtualDoc); // Start analysis on the virtual document const analyzedDoc = analyzer.analyze(virtualDoc); expect(analyzedDoc).toBeDefined(); expect(analyzedDoc.document.uri).toBe(virtualUri); await workspaceManager.analyzePendingDocuments(); workspaceManager.handleOpenDocument(virtualDoc); workspaceManager.handleUpdateDocument(virtualDoc); // Get diagnostics and cache them const diagnostics = await getDiagnosticsAsync(analyzedDoc.root!, virtualDoc); analyzer.diagnostics.requestUpdate(virtualUri); // analyzer.diagnostics.set(virtualUri, diagnostics); expect(diagnostics).toBeDefined(); expect(Array.isArray(diagnostics)).toBe(true); console.log({ diagnostics, docUri: virtualUri, content: fishContentWithErrors, }); // Verify we get some kind of diagnostics (syntax errors or semantic issues) const hasDiagnostics = diagnostics.length > 0; expect(hasDiagnostics).toBe(true); }); it('should track virtual documents that dont exist on system path', async () => { const nonExistentPath = 'file:///completely/non-existent/path/test.fish'; const fishContent = ` function virtual_only_func set -l virtual_var "this file doesn't exist on disk" echo $virtual_var end virtual_only_func `.trim(); // Create document with non-existent file path const virtualDoc = LspDocument.createTextDocumentItem(nonExistentPath, fishContent); testOpenDocument(virtualDoc); // Verify document is tracked even though path doesn't exist const retrievedDoc = documents.get(nonExistentPath); expect(retrievedDoc).toBeDefined(); expect(retrievedDoc?.uri).toBe(nonExistentPath); expect(retrievedDoc?.getText()).toBe(fishContent); // Analyze document and verify it works without file system access const analyzedDoc = analyzer.analyze(virtualDoc); expect(analyzedDoc).toBeDefined(); // Get symbols from the virtual document const symbols = analyzer.getDocumentSymbols(nonExistentPath); expect(symbols).toBeDefined(); expect(symbols.some(s => s.name === 'virtual_only_func')).toBe(true); // Verify we can get diagnostics even without physical file const diagnostics = await testDiagnosticsWrapper(analyzedDoc); expect(diagnostics).toBeDefined(); expect(Array.isArray(diagnostics)).toBe(true); }); it('should mirror textDocument/diagnostics request behavior', async () => { Config.isWebServer = true; const { server } = await FishServer.create(mockConnection, { processId: 0, rootUri: null, rootPath: null, capabilities: {}, initializationOptions: {}, workspaceFolders: [] } as LSP.InitializeParams); const virtualUri = 'memory://test-diagnostics-mirror.fish'; const fishContentForDiagnostics = ` function test_diagnostics echo "function with syntax issues" if test -n $argv echo "missing end for if statement" set $local_var "trying to set with dollar sign" `.trim(); // Open virtual document (simulates textDocument/didOpen) testOpenDocument( LspDocument.createTextDocumentItem( virtualUri, fishContentForDiagnostics, ), ); // // await server.didOpenTextDocument({ // textDocument: { // uri: virtualUri, // languageId: 'fish', // version: 1, // text: fishContentForDiagnostics, // }, // }); // Verify document is in collection and can be retrieved const doc = documents.get(virtualUri); expect(doc).toBeDefined(); expect(doc?.getText()).toBe(fishContentForDiagnostics); // Test that we can start analysis and generate diagnostics on virtual document const analyzedDoc = analyzer.analyze(doc!); expect(analyzedDoc).toBeDefined(); expect(analyzedDoc.document.uri).toBe(virtualUri); // Verify diagnostics can be retrieved for virtual document // analyzer.diagnostics.requestUpdate(virtualUri, true); const diagnostics = await testDiagnosticsWrapper(analyzedDoc); expect(diagnostics).toBeDefined(); expect(Array.isArray(diagnostics)).toBe(true); // Verify basic LSP functionality works with virtual documents expect(typeof virtualUri).toBe('string'); expect(virtualUri.includes('memory://')).toBe(true); }); it('should handle document updates and re-analyze for diagnostics', async () => { Config.isWebServer = true; const { server } = await FishServer.create(mockConnection, { processId: 0, rootUri: null, rootPath: null, capabilities: {}, initializationOptions: {}, workspaceFolders: [] } as LSP.InitializeParams); const virtualUri = 'memory://test-updates.fish'; const initialContent = ` function broken_func echo "missing end" set $invalid_var "dollar sign issue" `.trim(); // Open initial document with issues testOpenDocument( LspDocument.createTextDocumentItem( virtualUri, initialContent, ), ); // await server.didOpenTextDocument({ // textDocument: { // uri: virtualUri, // languageId: 'fish', // version: 1, // text: initialContent, // }, // }); // Verify initial document exists and can be analyzed const initialDoc = documents.get(virtualUri); expect(initialDoc).toBeDefined(); expect(initialDoc?.getText()).toBe(initialContent); // Manually analyze to get diagnostics const initialAnalyzed = analyzer.analyze(initialDoc!); expect(initialAnalyzed).toBeDefined(); const initialDiagnostics = await testDiagnosticsWrapper(initialAnalyzed); // Update document to fix the issues const fixedContent = ` function fixed_func echo "now properly closed" end `.trim(); // Simulate didChangeTextDocument from client testChangeDocument(virtualUri, fixedContent, 2); // await server.didChangeTextDocument({ // textDocument: { // uri: virtualUri, // version: 2, // }, // contentChanges: [{ text: fixedContent }], // }); // Verify document still exists after attempted update const updatedDoc = documents.get(virtualUri); expect(updatedDoc).toBeDefined(); // Test that we can manually update virtual document and re-analyze const manuallyUpdatedDoc = LspDocument.createTextDocumentItem(virtualUri, fixedContent); testOpenDocument(manuallyUpdatedDoc); // documents.set(manuallyUpdatedDoc); const updatedAnalyzed = analyzer.analyze(manuallyUpdatedDoc); expect(updatedAnalyzed).toBeDefined(); expect(updatedAnalyzed.document.getText()).toBe(fixedContent); const updatedDiagnostics = await testDiagnosticsWrapper(updatedAnalyzed); // Basic verification that we can track diagnostics over document changes expect(Array.isArray(initialDiagnostics)).toBe(true); expect(Array.isArray(updatedDiagnostics)).toBe(true); // Verify we can handle virtual document lifecycle expect(typeof virtualUri).toBe('string'); expect(virtualUri.includes('memory://')).toBe(true); }); it('should handle non-fish file extensions with virtual URIs', async () => { const virtualUri = 'memory://test.notfish'; const fishContent = ` function test_non_fish_extension echo "content is fish but extension is not" end `.trim(); // Create document with non-fish extension but fish content const doc = LspDocument.createTextDocumentItem(virtualUri, fishContent); testOpenDocument(doc); // Should still be able to analyze const analyzedDoc = analyzer.analyze(doc); expect(analyzedDoc).toBeDefined(); // Should extract symbols regardless of extension const symbols = analyzer.getDocumentSymbols(virtualUri); expect(symbols.some(s => s.name === 'test_non_fish_extension')).toBe(true); // Should provide diagnostics const diagnostics = await testDiagnosticsWrapper(analyzedDoc); expect(diagnostics).toBeDefined(); expect(Array.isArray(diagnostics)).toBe(true); }); }); }); ================================================ FILE: tests/workspace-manager.test.ts ================================================ import { createFakeLspDocument, fishLocations, FishLocations, setLogger } from './helpers'; import { LspDocument, documents } from '../src/document'; import { Analyzer } from '../src/analyze'; import { workspaceManager } from '../src/utils/workspace-manager'; import * as path from 'path'; import { mkdirSync, rm, writeFileSync } from 'fs'; import { Workspace } from '../src/utils/workspace'; import { pathToUri } from '../src/utils/translation'; import { testChangeDocument, testClearDocuments, testOpenDocument } from './document-test-helpers'; let locations: FishLocations; describe('new-workspace-manager', () => { setLogger(); const testWorkspace1Path = path.join('/tmp', 'test_workspace_1'); const testWorkspace2Path = path.join('/tmp', 'test_workspace_2'); const testWorkspace3Path = path.join('/tmp', 'test_workspace_3'); const testWorkspace4Path = path.join('/tmp', 'test_workspace_4'); const testWorkspaceSkeleton = [ { dirpath: testWorkspace1Path, docs: [ createFakeLspDocument( path.join(testWorkspace1Path, 'config.fish'), `source ${testWorkspace3Path}/functions/func1.fish`, `source ${testWorkspace3Path}/functions/func2.fish`, `source ${testWorkspace3Path}/functions/func3.fish`, `source ${testWorkspace3Path}/functions/func4.fish`, ), ], }, { dirpath: testWorkspace2Path, docs: [ createFakeLspDocument( path.join(testWorkspace2Path, '.env.fish'), `source ${testWorkspace3Path}/functions/func1.fish`, `source ${testWorkspace3Path}/functions/func2.fish`, `source ${testWorkspace3Path}/functions/func3.fish`, `source ${testWorkspace3Path}/functions/func4.fish`, ), ], }, { dirpath: testWorkspace3Path, docs: [ createFakeLspDocument( path.join(testWorkspace3Path, 'functions', 'func1.fish'), 'function func1', ' echo "func1"', 'end', ), createFakeLspDocument( path.join(testWorkspace3Path, 'functions', 'func2.fish'), 'function func2', ' echo "func2"', 'end', ), createFakeLspDocument( path.join(testWorkspace3Path, 'functions', 'func3.fish'), 'function func3', ' echo "func3"', ' end', ), createFakeLspDocument( path.join(testWorkspace3Path, 'functions', 'func4.fish'), 'function func4', ' echo "func4"', 'end', ), ], }, { dirpath: testWorkspace4Path, docs: [ createFakeLspDocument( path.join(testWorkspace4Path, 'conf.d', 'load_1.fish'), `source ${testWorkspace3Path}/functions/func1.fish`, ), createFakeLspDocument( path.join(testWorkspace4Path, 'conf.d', 'load_2.fish'), `source ${testWorkspace3Path}/functions/func2.fish`, ), createFakeLspDocument( path.join(testWorkspace4Path, 'conf.d', 'load_3.fish'), `source ${testWorkspace3Path}/functions/func3.fish`, ), createFakeLspDocument( path.join(testWorkspace4Path, 'conf.d', 'load_4.fish'), `source ${testWorkspace3Path}/functions/func4.fish`, ), ], }, ]; beforeAll(async () => { locations = await fishLocations(); for (const { dirpath, docs } of testWorkspaceSkeleton) { mkdirSync(dirpath, { recursive: true }); // make subdirectories for dirs that use them if (![testWorkspace1Path, testWorkspace2Path].includes(dirpath)) { ['conf.d', 'functions', 'completions'].forEach((subdir) => { const subdirPath = path.join(dirpath, subdir); mkdirSync(subdirPath, { recursive: true }); }); } docs.forEach((doc) => { const filepath = doc.path; writeFileSync(filepath, doc.getText()); }); } }); afterAll(async () => { for (const { dirpath } of testWorkspaceSkeleton) { rm(dirpath, { recursive: true, force: true }, (err) => { }); } }); // beforeEach(async () => { // parser = await initializeParser(); // analyzer = new Analyzer(parser); // documents.clear(); // for (const { dirpath, docs } of testWorkspaceSkeleton) { // const workspace = Workspace.syncCreateFromUri(pathToUri(dirpath))!; // workspaceManager.addWorkspace(workspace); // docs.forEach((doc) => { // workspace.addUri(doc.uri); // documents.open(doc); // }); // } // workspaces.copy(workspaceManager); // // await analyzer.initiateBackgroundAnalysis() // }); beforeEach(async () => { await Analyzer.initialize(); testClearDocuments(); workspaceManager.clear(); }); afterEach(() => { testClearDocuments(); workspaceManager.clear(); }); describe('setup 1', () => { beforeEach(() => { workspaceManager.clear(); testClearDocuments(); testWorkspaceSkeleton.forEach(({ dirpath, docs }) => { const newWorkspace = Workspace.syncCreateFromUri(pathToUri(dirpath)); if (!newWorkspace) { throw new Error(`Failed to create workspace from ${dirpath}`); } workspaceManager.add(newWorkspace); docs.forEach((doc) => { newWorkspace.uris.add(doc.uri); // testOpenDocument(doc) }); workspaceManager.setCurrent(newWorkspace); }); }); it('check length', () => { expect(workspaceManager.all).toHaveLength(4); }); // it.skip('check ws 1', async () => { // const ws1 = workspaceManager.all.at(0)!; // const focusedDoc = ws1.allDocuments().at(0)!; // // console.log({ // // ws1: { // // uri: ws1.uri, // // uris: ws1.uris, // // focusedDoc: focusedDoc.uri, // // isFocusedDoc: LspDocument.is(focusedDoc), // // } // // }); // // workspaceManager.handleOpenDocument(focusedDoc); // expect(workspaceManager.current).toEqual(ws1); // // console.log({ // // documents: documents.all().map((doc) => doc.uri), // // analyzedUris: ws1.allAnalyzedUris, // // unanalyzedUris: ws1.allUnanalyzedUris, // // allUris: ws1.allUris, // // }); // const ws2 = workspaceManager.all.at(1)!; // let focusedDoc2 = ws2.allDocuments().at(0)!; // workspaceManager.handleOpenDocument(focusedDoc2); // // documents.applyChanges(focusedDoc2.uri, [ // // { // // text: [focusedDoc2.getText(), `source ${focusedDoc.path}`].join('\n'), // // }, // // ]); // testChangeDocument(focusedDoc2.uri, [focusedDoc2.getText(), `source ${focusedDoc.path}`].join('\n')) // focusedDoc2 = documents.get(focusedDoc2.uri)!; // workspaceManager.handleUpdateDocument(focusedDoc2); // console.log({ // ws2: { // uri: ws2.uri, // uris: ws2.uris, // focusedDoc: focusedDoc2.uri, // isFocusedDoc: LspDocument.is(focusedDoc2), // // openedDocs: documents.openDocuments.map((doc) => doc.uri), // }, // }); // workspaceManager.handleCloseDocument(focusedDoc2); // // console.log({ // // documents: documents.all().map((doc) => doc.uri), // // currentWS: workspaceManager.current?.uri, // // }); // expect(documents.all().map((doc) => doc.uri)).toHaveLength(1); // expect(workspaceManager.current).toEqual(ws1); // }); it('didChangeWorkspace', () => { const ws1 = workspaceManager.all.at(0)!; const focusedDoc = ws1.allDocuments().at(0)!; workspaceManager.handleOpenDocument(focusedDoc); expect(workspaceManager.current).toEqual(ws1); const ws2 = workspaceManager.all.at(1)!; const ws3 = workspaceManager.all.at(2)!; const ws4 = workspaceManager.all.at(3)!; workspaceManager.handleWorkspaceChangeEvent({ added: [ { uri: ws2.uri, name: ws2.name, }, { uri: ws3.uri, name: ws3.name, }, { uri: ws4.uri, name: ws4.name, }, ], removed: [ { uri: ws1.uri, name: ws1.name, }, ], }); workspaceManager.setCurrent(ws4); expect(workspaceManager.current).toEqual(ws4); }); it('check ws __fish_config_dir', async () => { const workspaces = [ ...workspaceManager.all, Workspace.syncCreateFromUri(locations.uris.fish_config.dir)!, Workspace.syncCreateFromUri(locations.uris.fish_data.dir)!, Workspace.syncCreateFromUri(locations.uris.test_workspace.dir)!, ]; workspaceManager.clear(); workspaces.forEach((ws) => { workspaceManager.add(ws); }); // const newWorkspace = Workspace.syncCreateFromUri(locations.uris.fish_config.dir)!; // workspaceManager.add(newWorkspace); // workspaceManager.handleOpenDocument(newWorkspace.allDocuments().at(0)!); const result = await workspaceManager.analyzePendingDocuments(); console.log({ items: Object.entries(result.items).map(([key, value]) => ({ key, value: value.length, })), total: result.totalDocuments, }); workspaceManager.handleOpenDocument(locations.uris.fish_config.config); workspaceManager.handleOpenDocument(locations.uris.fish_data.config); workspaceManager.handleCloseDocument(locations.uris.fish_data.config); }); }); }); ================================================ FILE: tests/workspace-util.ts ================================================ import fs from 'fs'; import * as path from 'path'; import { documents, LspDocument } from '../src/document'; import { randomBytes } from 'crypto'; import { Analyzer, analyzer } from '../src/analyze'; import { workspaceManager } from '../src/utils/workspace-manager'; import { Workspace } from '../src/utils/workspace'; import { pathToUri, uriToPath } from '../src/utils/translation'; import { setupProcessEnvExecFile } from '../src/utils/process-env'; import { logger } from '../src/logger'; import { execFileSync } from 'child_process'; import { SyncFileHelper } from '../src/utils/file-operations'; function generateRandomWorkspaceName(): string { const timestamp = Date.now().toString(36); const random = randomBytes(3).toString('hex'); return `test_workspace_${timestamp}_${random}`; } // type TestFileType = 'function' | 'config' | 'completion' | 'conf.d' | 'autoloaded' | 'script'; export type QueryPathType = 'functions' | 'completions' | 'conf.d' | 'config.fish' | 'autoloaded' | 'scripts' | 'any'; export class QueryConfig { public nameMatch?: string | RegExp; public pathMatch?: string | RegExp; public onlyMatchesPathType?: QueryPathType[]; public allMatchesPathType?: QueryPathType[]; static is(config: any): config is QueryConfig { if (!config || typeof config !== 'object' || Array.isArray(config) || LspDocument.is(config) || typeof config === 'string') { return false; } return ( typeof config === 'object' && (config.nameMatch !== undefined && typeof config.nameMatch === 'string' || config.nameMatch instanceof RegExp) || (config.pathMatch !== undefined && typeof config.pathMatch === 'string' || config.pathMatch instanceof RegExp) || config.onlyMatchesPathType !== undefined && Array.isArray(config.onlyMatchesPathType) || config.allMatchesPathType !== undefined && Array.isArray(config.allMatchesPathType) ); } static to(config: QueryConfig): Query { if (!QueryConfig.is(config)) { throw new Error('Invalid QueryConfig'); } let query = Query.create(); if (config.nameMatch) { query = query.withName(config.nameMatch.toString()); } if (config.pathMatch) { query = query.withPath(config.pathMatch.toString()); } if (config.onlyMatchesPathType) { for (const type of config.onlyMatchesPathType) { switch (type) { case 'functions': query = query.functions(); break; case 'completions': query = query.completions(); break; case 'conf.d': query = query.confd(); break; case 'config.fish': query = query.config(); break; case 'autoloaded': query = query.autoloaded(); break; case 'scripts': query = query.scripts(); break; case 'any': query = query.autoloaded() .scripts() .functions() .completions() .confd() .config(); // No specific filter, matches all break; } } } return query; } } /** * Query builder for advanced document selection */ export class Query { private _filters: ((doc: LspDocument) => boolean)[] = []; private _returnFirst = false; private constructor() { } public static is(query: unknown): query is Query { if (!query || typeof query !== 'object') { return false; } return query instanceof Query; } public static fromConfig(config: QueryConfig | string | unknown): Query { if (typeof config === 'string') { // If it's a string, treat it as a name match return Query.create().withName(config) || Query.create().withPath(config); } if (QueryConfig.is(config)) { return QueryConfig.to(config); } return new Query(); } /** * Creates a new query */ static create(): Query { return new Query(); } /** * Filters for function files in functions/ directory */ static functions(): Query { return new Query().functions(); } /** * Filters for completion files in completions/ directory */ static completions(): Query { return new Query().completions(); } /** * Filters for config.fish files */ static config(): Query { return new Query().config(); } /** * Filters for conf.d files */ static confd(): Query { return new Query().confd(); } /** * Filters for script files (non-autoloaded) */ static scripts(): Query { return new Query().scripts(); } /** * Filters for any autoloaded files */ static autoloaded(): Query { return new Query().autoloaded(); } /** * Filters by file name */ static withName(name: string): Query { return new Query().withName(name); } /** * Filters by path pattern */ static withPath(...patterns: string[]): Query { return new Query().withPath(...patterns); } /** * Returns only the first match */ static firstMatch(): Query { return new Query().firstMatch(); } // Instance methods for chaining /** * Filters for function files in functions/ directory */ functions(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return docPath.includes('/functions/') && docPath.endsWith('.fish'); }); return this; } /** * Filters for completion files in completions/ directory */ completions(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return docPath.includes('/completions/') && docPath.endsWith('.fish'); }); return this; } /** * Filters for config.fish files */ config(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return path.basename(docPath) === 'config.fish'; }); return this; } /** * Filters for conf.d files */ confd(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return docPath.includes('/conf.d/') && docPath.endsWith('.fish'); }); return this; } /** * Filters for script files (non-autoloaded) */ scripts(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return ( docPath.includes('/scripts/') || !docPath.includes('/functions/') && !docPath.includes('/completions/') && !docPath.includes('/conf.d/') && path.basename(docPath) !== 'config.fish' ) && docPath.endsWith('.fish'); }); return this; } /** * Filters for any autoloaded files */ autoloaded(): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return ( docPath.includes('/functions/') || docPath.includes('/completions/') || docPath.includes('/conf.d/') || path.basename(docPath) === 'config.fish' ) && docPath.endsWith('.fish'); }); return this; } /** * Filters by file name (with or without .fish extension) */ withName(name: string): Query { this._filters.push(doc => { const basename = path.basename(doc.path, '.fish'); const basenameWithExt = path.basename(doc.path); return basename === name || basenameWithExt === name || doc.getFileName().includes(name); }); return this; } /** * Filters by path patterns */ withPath(...patterns: string[]): Query { this._filters.push(doc => { const docPath = uriToPath(doc.uri); return patterns.some(pattern => docPath.includes(pattern)); }); return this; } /** * Returns only the first match */ firstMatch(): Query { this._returnFirst = true; return this; } /** * Executes the query against a list of documents */ execute(documents: LspDocument[]): LspDocument[] { let result = documents; // Apply all filters for (const filter of this._filters) { result = result.filter(filter); } // Return first match if requested if (this._returnFirst) { return result.slice(0, 1); } return result; } } export type QueryPropsType = Query | QueryConfig | string; // Simplified TestFile class (removing BaseTestFile duplication) export class TestFile { private hasWritten = false; public static baseDir = path.resolve('tests/workspaces'); private constructor( public relativePath: string, public content: string | string[], public rootPath: string = TestFile.baseDir, ) { } get absPath(): string { return path.join(this.rootPath, this.relativePath); } toDocument(): LspDocument { if (!fs.existsSync(this.absPath)) { this.writeFile(); } return LspDocument.createFromPath(this.absPath); } getType() { return this.toDocument().getAutoloadType(); } withShebang(shebang: string = '#!/usr/bin/env fish'): TestFile { // Add shebang to the content if it's a string this.content = Array.isArray(this.content) ? [shebang, ...this.content] : `${shebang}\n${this.content}`; if (this.hasWritten) { // If the file has already been written, we need to rewrite it this.writeFile(); this.hasWritten = true; } return this; } writeFile() { const dir = path.dirname(this.absPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(this.absPath, Array.isArray(this.content) ? this.content.join('\n') : this.content, 'utf8'); this.hasWritten = true; return this; } static create( relativePath: string, content: string | string[] = '', rootPath: string = TestFile.baseDir, ): TestFile { return new TestFile(relativePath, content, rootPath).writeFile(); } get relativeUri() { return pathToUri(this.relativePath); } get uri() { if (!this.hasWritten) { this.writeFile(); } return pathToUri(this.absPath); } // static fromDocument(doc: LspDocument): TestFile { // return new TestFile(doc.getRelativeFilenameToWorkspace(), doc.getText()); // } // /** * Creates a function file in the functions/ directory */ static function(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`functions/${filename}`, content); } /** * Creates a completion file in the completions/ directory */ static completion(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`completions/${filename}`, content); } /** * Creates a config.fish file */ static config(content: string | string[]) { return new TestFile('config.fish', content); } /** * Creates a conf.d file */ static confd(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`conf.d/${filename}`, content); } /** * Creates a script file (non-autoloaded) */ static script(name: string, content: string | string[]) { const filename = name.endsWith('.fish') ? name : `${name}.fish`; return new TestFile(`${filename}`, content); } /** * Creates a custom file at any relative path */ static custom(relativePath: string, content: string | string[]) { return new TestFile(relativePath, content); } static fromDoc(doc: LspDocument): TestFile { return new TestFile(doc.getRelativeFilenameToWorkspace(), doc.getText()); } // writeFile() { // const absPath = path.join(TestFile.rootDirPath, this.relativePath); // const dir = path.dirname(absPath); // if (!fs.existsSync(dir)) { // fs.mkdirSync(dir, { recursive: true }); // } // fs.writeFileSync(absPath, Array.isArray(this.content) ? this.content.join('\n') : this.content, 'utf8'); // } // // withShebang(shebang: string = '#!/usr/bin/env fish'): TestFile { // // Add shebang to the content if it's a string // const contentWithShebang = Array.isArray(this.content) // ? [shebang, ...this.content] // : `${shebang}\n${this.content}`; // // return new TestFile(this.relativePath, contentWithShebang); // } } type TestWorkspaceForceUtil = { remove: () => TestWorkspace; initialize: () => TestWorkspace; // Default to sync for convenience initializeSync: () => TestWorkspace; // Explicit sync method snapshot: () => string; inspect: () => TestWorkspace; reset: () => TestWorkspace; overwrite: () => TestWorkspace; }; export default class TestWorkspace { private files: TestFile[] = []; private _uniqDocuments: Set = new Set(); private _documents: LspDocument[] = []; private initialized: boolean = false; private _inspecting: boolean = false; public workspacePath: string; private _alwaysSnapshot: boolean = false; private _lazyRegister?: () => void; public static ROOT_PATH = path.resolve('tests/workspaces'); constructor( public readonly name: string = generateRandomWorkspaceName(), public readonly _isCurrent: boolean = true, public readonly config: Record = {}, ) { this.workspacePath = path.join(TestWorkspace.ROOT_PATH, this.name); if (fs.existsSync(this.workspacePath)) { let counter = 1; let newName = `${this.workspacePath}_${counter}`; while (fs.existsSync(newName)) { newName = `${this.workspacePath}_${counter}`; counter++; } fs.mkdirSync(newName, { recursive: true }); } TestFile.baseDir = this.workspacePath; } get absPath() { if (!this.initialized && !fs.existsSync(this.workspacePath)) { fs.mkdirSync(this.workspacePath, { recursive: true }); } return this.workspacePath; } get workspaceUri() { return pathToUri(this.workspacePath); } isCurrent() { if (!this.initialized) { this.initialize(); } workspaceManager.setCurrent(this.workspace); return this; } add(...files: TestFile[]) { let workspace = workspaceManager.current; if (!this.initialized) { workspace = Workspace.syncCreateFromUri(this.workspaceUri)!; } for (const file of files) { console.log(file.absPath); file.writeFile(); if (fs.existsSync(file.absPath)) { console.log(`${file.absPath} exists`); } else { console.log(`${file.absPath} DOESNT exists`); } console.log({ uri: file.uri, relativeUri: file.relativeUri, absPath: file.absPath, relativePath: file.relativePath, isInititialize: this.initialized, isCurrent: this._isCurrent, name: this.name, file: file.rootPath, workspace: workspaceManager.current?.uri, }); } this.files.push(...files); files.forEach(file => { file.writeFile(); workspace!.add(file.toDocument().uri); file.writeFile(); this.files.push(file); if (!this._uniqDocuments.has(file.absPath)) { this._documents.push(file.toDocument()); this._uniqDocuments.add(file.absPath); workspace?.addPending(file.relativeUri); } }); // if (this.initialized) { // workspaceManager.current!.addPending(...this._documents.map(doc => doc.uri)); // } return this; } // Don't remove the workspace after the test finishes inspect() { this._inspecting = true; } private _isValidFishDir(relativePath: string): boolean { return relativePath.startsWith('functions/') || relativePath.startsWith('completions/') || relativePath.startsWith('conf.d/') || relativePath === 'config.fish'; } copyFromAutoloadedEnvVariable(sourcePath: string) { if (sourcePath.startsWith('$')) { try { const stdout = execFileSync('fish', ['-c', `echo ${sourcePath}`]).toString().trim(); if (stdout !== sourcePath && !fs.existsSync(sourcePath) && fs.existsSync(stdout)) { sourcePath = stdout; } } catch (error) { if (this.config.debug) { logger.error(`Failed to expand environment variable: ${sourcePath}`); } return this; } } if (SyncFileHelper.isExpandable(sourcePath) && !SyncFileHelper.isAbsolutePath(sourcePath)) { sourcePath = SyncFileHelper.expandEnvVars(sourcePath); } if (!fs.existsSync(sourcePath)) { if (this.config.debug) { logger.warning(`Source path does not exist: ${sourcePath}`); } return this; } const fishDirs = ['functions', 'completions', 'conf.d']; const configFile = 'config.fish'; const inheritedDocuments: LspDocument[] = []; // Copy config.fish if it exists const configPath = path.join(sourcePath, configFile); if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, 'utf8'); this.add(TestFile.config(content)); // Create document for tracking const configDoc = LspDocument.createFromUri(pathToUri(configPath)); inheritedDocuments.push(configDoc); if (this.config.debug) { logger.log(`Inherited config.fish from: ${configPath}`); } } // Copy files from fish directories for (const dir of fishDirs) { const dirPath = path.join(sourcePath, dir); if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { const files = fs.readdirSync(dirPath).filter(file => file.endsWith('.fish')); for (const file of files) { const filePath = path.join(dirPath, file); const relativePath = `${dir}/${file}`; if (this._uniqDocuments.has(path.join(TestFile.baseDir, relativePath))) continue; const content = fs.readFileSync(filePath, 'utf8'); // Add to files list this.add(TestFile.create(relativePath, content)); // Create document for tracking const doc = LspDocument.createFromUri(pathToUri(filePath)); inheritedDocuments.push(doc); if (this.config.debug) { logger.log(`Inherited ${relativePath} from: ${filePath}`); } } } } // If workspace is already initialized, add documents to it if (this.workspace) { for (const doc of inheritedDocuments) { this.workspace.addPending(doc.uri); } } if (this.config.debug) { logger.log(`Inherited ${inheritedDocuments.length} files from: ${sourcePath}`); } return this; } /** * Auto-registers hooks with the test framework for immediate use * Note: This method registers hooks immediately, not compatible with describe.skip() * * @deprecated Consider using getHooks() or initializeLazy() for better control * * @example * ```typescript * const workspace = TestWorkspace.create('test').add(...files).initialize(); * // Hooks are registered automatically - workspace is ready to use * ``` */ initialize() { logger.setSilent(); const workspace = Workspace.syncCreateFromUri(pathToUri(this.workspacePath))!; // analyzer workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); if (!fs.existsSync(this.workspacePath)) { fs.mkdirSync(this.workspacePath, { recursive: true }); } // analyzer.analyzeWorkspace(worskpace) // const workspace = Workspace.syncCreateFromUri(pathToUri(this.workspacePath))!; // workspaceManager.add(workspace); // if (workspace) { // workspaceManager.add(workspace); // } // const setup = async () => { // logger.setSilent(); // await setupProcessEnvExecFile(); // await Analyzer.initialize(); // if (!workspace) { // throw new Error(`Failed to create workspace from URI: ${this.workspaceUri}`); // } // workspaceManager.add(workspace); // workspaceManager.setCurrent(workspace); // }; // // const beforeEach = async () => { // logger.setSilent(); // workspace?.setAllPending(); // this.initialized = false; // this._documents.forEach(doc => { // if (!fs.existsSync(doc.path)) { // fs.writeFileSync(doc.path, doc.getText(), 'utf8'); // } // workspace?.addPending(doc.uri); // }); // await workspaceManager.analyzePendingDocuments(); // this.initialized = true; // }; // // const teardown = async () => { // if (this._alwaysSnapshot) { // this.writeSnapshot(); // } // if (!this.initialized) return; // workspaceManager.clear(); // this.initialized = false; // this._documents = []; // this._uniqDocuments.clear(); // this.files = []; // if (!this._inspecting && fs.existsSync(this.workspacePath)) { // fs.rmSync(this.workspacePath, { recursive: true }); // } // }; // // return { // setup, // teardown, // beforeEach, // workspace: this, // }; beforeEach(async () => { logger.setSilent(); this.initialized = false; // this.files = []; workspaceManager.add(workspace); this._documents.forEach(doc => { if (!fs.existsSync(doc.path)) { fs.writeFileSync(doc.path, doc.getText(), 'utf8'); } workspace?.addPending(doc.uri); this.files.push(TestFile.fromDoc(doc)); }); workspace?.setAllPending(); workspace.add(...this._documents.map(doc => doc.uri)); workspace.add(...this.files.map(file => pathToUri(file.absPath))); workspaceManager.add(workspace); // await analyzer.analyzeWorkspace(workspace); await workspaceManager.analyzePendingDocuments(); this.initialized = true; workspaceManager.setCurrent(workspace); }); afterEach(async () => { if (this._alwaysSnapshot) { this.writeSnapshot(); } if (!this.initialized) return; workspaceManager.clear(); this.initialized = false; // this._documents = []; this._uniqDocuments.clear(); // this.files = []; if (!this._inspecting && fs.existsSync(this.workspacePath)) { fs.rmSync(this.workspacePath, { recursive: true }); } }); beforeAll(async () => { logger.setSilent(); await setupProcessEnvExecFile(); await Analyzer.initialize(); this._documents.map(doc => doc.uri).forEach(u => workspaceManager.current?.add(u)); workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); workspace.add(...this._documents.map(doc => doc.uri)); }); return this; } /** * Returns setup and teardown functions without registering them with the test framework * Use this for manual hook registration or describe.skip() compatible scenarios * * @example * ```typescript * const workspace = TestWorkspace.create('test').add(...files); * const { setup, teardown, beforeEach } = workspace.getHooks(); * * beforeAll(setup); * beforeEach(beforeEach); * afterAll(teardown); * ``` */ getHooks(): { setup: () => Promise; teardown: () => Promise; beforeEach: () => Promise; } { logger.setSilent(); if (!this.initialized) { if (!fs.existsSync(this.workspacePath)) { fs.mkdirSync(this.workspacePath, { recursive: true }); } } const workspace = Workspace.syncCreateFromUri(this.workspaceUri)!; const setup = async () => { logger.setSilent(); await setupProcessEnvExecFile(); await Analyzer.initialize(); if (!workspace) { throw new Error(`Failed to create workspace from URI: ${this.workspaceUri}`); } workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); }; const beforeEach = async () => { this.files = []; logger.setSilent(); workspace?.setAllPending(); this.initialized = false; this._documents.forEach(doc => { this.files.push(TestFile.fromDoc(doc)); if (!fs.existsSync(doc.path)) { fs.writeFileSync(doc.path, doc.getText(), 'utf8'); } workspace?.addPending(doc.uri); }); await workspaceManager.analyzePendingDocuments(); this.initialized = true; }; const teardown = async () => { if (this._alwaysSnapshot) { this.writeSnapshot(); } if (!this.initialized) return; workspaceManager.clear(); this.initialized = false; this._documents = []; this._uniqDocuments.clear(); this.files = []; if (!this._inspecting && fs.existsSync(this.workspacePath)) { fs.rmSync(this.workspacePath, { recursive: true }); } }; return { setup, teardown, beforeEach, }; } /** * Lazy initialization that respects describe.skip() * Only registers hooks when workspace properties are actually accessed * * @example * ```typescript * describe.skip('skipped tests', () => { * const workspace = TestWorkspace.create('test').add(...files).initializeLazy(); * // No hooks are registered, no setup occurs since tests are skipped * }); * * describe('active tests', () => { * const workspace = TestWorkspace.create('test').add(...files).initializeLazy(); * * it('should work', () => { * // Hooks get registered here when workspace.documents is accessed * expect(workspace.documents.length).toBe(2); * }); * }); * ``` */ initializeLazy(): TestWorkspace { let hooksRegistered = false; const registerHooksOnce = () => { if (hooksRegistered) return; hooksRegistered = true; const { setup, teardown, beforeEach } = this.getHooks(); beforeAll.bind(setup); beforeEach.bind(beforeEach); afterAll.bind(teardown); }; this._lazyRegister = registerHooksOnce; return this; } /** * Synchronous force initialization for immediate workspace setup * Use this when you need workspace ready without async/await * Note: Documents will be added but not analyzed until later */ forceInitializeSync(): TestWorkspace { logger.setSilent(); if (this.initialized) { return this; } if (!fs.existsSync(this.workspacePath)) { fs.mkdirSync(this.workspacePath, { recursive: true }); } const workspace = Workspace.syncCreateFromUri(this.workspaceUri)!; if (!workspace) { throw new Error(`Failed to create workspace from URI: ${this.workspaceUri}`); } this._documents.forEach(doc => { if (!fs.existsSync(doc.path)) { fs.writeFileSync(doc.path, doc.getText(), 'utf8'); } workspace?.addPending(doc.uri); }); workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); // Note: Analysis is skipped in sync version // Documents are added but not analyzed // Use forceInitializeAsync() if you need full analysis this.initialized = true; return this; } /** * Force initialization with full async analysis * Use this when you need all documents analyzed immediately */ async forceInitializeAsync(): Promise { logger.setSilent(); if (this.initialized) { return this; } if (!fs.existsSync(this.workspacePath)) { fs.mkdirSync(this.workspacePath, { recursive: true }); } const workspace = Workspace.syncCreateFromUri(this.workspaceUri)!; if (!workspace) { throw new Error(`Failed to create workspace from URI: ${this.workspaceUri}`); } this._documents.forEach(doc => { if (!fs.existsSync(doc.path)) { fs.writeFileSync(doc.path, doc.getText(), 'utf8'); } workspace?.addPending(doc.uri); }); workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); // Properly await document analysis await workspaceManager.analyzePendingDocuments(); this.initialized = true; return this; } /** * Async force initialization (legacy method) * @deprecated Use forceInitializeSync for better performance */ async forceInitialize(): Promise { logger.setSilent(); await setupProcessEnvExecFile(); await Analyzer.initialize(); if (this.initialized) { return this; } if (!fs.existsSync(this.workspacePath)) { fs.mkdirSync(this.workspacePath, { recursive: true }); } const workspace = Workspace.syncCreateFromUri(this.workspaceUri)!; if (!workspace) { throw new Error(`Failed to create workspace from URI: ${this.workspaceUri}`); } this._documents.forEach(doc => { if (!fs.existsSync(doc.path)) { fs.writeFileSync(doc.path, doc.getText(), 'utf8'); } workspace?.addPending(doc.uri); }); workspaceManager.add(workspace); workspaceManager.setCurrent(workspace); workspace.setAllPending(); await workspaceManager.analyzePendingDocuments(); this.initialized = true; return this; } get workspace(): Workspace { // Trigger lazy registration if configured if (this._lazyRegister) { this._lazyRegister(); this._lazyRegister = undefined; // Clear after first use } if (!this.initialized) { this.initialize(); } return workspaceManager.current!; } get documents(): LspDocument[] { // Trigger lazy registration if configured if (this._lazyRegister) { this._lazyRegister(); this._lazyRegister = undefined; // Clear after first use } if (!this.initialized) { this.initialize(); } return this._documents; } // Unified document access methods (DRY principle) public get( ...queryProps: QueryPropsType[] ): LspDocument | undefined { return this.filter(...queryProps)[0]; } findDocumentByPath(searchPath: string): LspDocument | undefined { return this.filter(Query.create().withPath(searchPath))[0]; } findDocumentsByName(name: string): LspDocument[] { return this.filter(Query.create().withName(name)); } writeSnapshot(outputPath?: string): string { const timestamp = Date.now(); const snapshotPath = outputPath || path.join(TestFile.baseDir, `${this.name}.snapshot`); const snapshot = JSON.stringify({ path: this.workspacePath, files: this.documents.map(doc => ({ path: doc.path, text: doc.getText() })), timestamp, }, null, 2); fs.writeFileSync(snapshotPath, snapshot, 'utf8'); return snapshotPath; } static findSnapshotPath(searchWorkspace: string | TestWorkspace) { if (searchWorkspace instanceof TestWorkspace) { return path.join(TestFile.baseDir, `${searchWorkspace.name}.snapshot`); } else if (typeof searchWorkspace === 'string') { if (fs.existsSync(path.join(TestFile.baseDir, `${searchWorkspace}.snapshot`))) { return path.join(TestFile.baseDir, `${searchWorkspace}.snapshot`); } else if (fs.existsSync(path.join(TestFile.baseDir, `${searchWorkspace}.json`))) { return path.join(TestFile.baseDir, `${searchWorkspace}.json`); } } // outputPath || path.join(TestFile._baseDir, `${this.name}.snapshot`); return undefined; } static fromSnapshot(path: string): TestWorkspace { if (!fs.existsSync(path)) { throw new Error(`Snapshot file does not exist: ${path}`); } const data = fs.readFileSync(path, 'utf8'); const snapshot = JSON.parse(data); const newWorkspace = new TestWorkspace(snapshot.path, false); if (!snapshot || !snapshot.path || !Array.isArray(snapshot.files)) { throw new Error(`Invalid snapshot format in file: ${path}`); } const files = snapshot.files.map((file: { path: string; text: string; }) => { if (!file.path || typeof file.text !== 'string') { throw new Error(`Invalid file entry in snapshot: ${JSON.stringify(file)}`); } fs.writeFileSync(file.path, file.text, 'utf8'); return { path: file.path, text: file.text }; }); newWorkspace.add( ...files, ); return newWorkspace; } readSnapshot(path: string) { if (!fs.existsSync(path)) { throw new Error(`Snapshot file does not exist: ${path}`); } const data = fs.readFileSync(path, 'utf8'); const snapshot = JSON.parse(data); const newWorkspace = new TestWorkspace(snapshot.path, false); if (!snapshot || !snapshot.path || !Array.isArray(snapshot.files)) { throw new Error(`Invalid snapshot format in file: ${path}`); } const files = snapshot.files.map((file: { path: string; text: string; }) => { if (!file.path || typeof file.text !== 'string') { throw new Error(`Invalid file entry in snapshot: ${JSON.stringify(file)}`); } fs.writeFileSync(file.path, file.text, 'utf8'); return { path: file.path, text: file.text }; }); newWorkspace.add( ...files, ); return newWorkspace; } /** * Dumps the file tree structure */ dumpFileTree(): string { if (!fs.existsSync(this.workspacePath)) { return 'Workspace not created yet'; } 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(this.name + '/'); buildTree(this.workspacePath, ''); return tree.join('\n'); } /** * Dumps tree-sitter parse trees for all documents in the workspace * Mimics the output format of tree-sitter CLI */ dumpParseTrees(): string { if (!this.initialized) { this.initialize(); } const output: string[] = []; for (const doc of this.documents) { const analyzedDoc = analyzer.analyze(doc); if (!analyzedDoc?.tree) { output.push(`=== ${doc.path} ===`); output.push('No parse tree available'); output.push(''); continue; } output.push(`=== ${doc.path} ===`); output.push(this.formatParseTree(analyzedDoc.tree.rootNode)); output.push(''); } return output.join('\n'); } /** * Dumps tree-sitter parse tree for a specific document */ dumpParseTree(pathOrQuery: string): string { if (!this.initialized) { this.initialize(); } const doc = this.find(pathOrQuery); if (!doc) { return `Document not found: ${pathOrQuery}`; } const analyzedDoc = analyzer.analyze(doc); if (!analyzedDoc?.tree) { return `No parse tree available for: ${pathOrQuery}`; } return this.formatParseTree(analyzedDoc.tree.rootNode); } /** * Formats a syntax node tree in tree-sitter CLI style */ private formatParseTree(node: any, indent = ''): string { const lines: string[] = []; if (!node) { return 'No node provided'; } // Format the current node const nodeInfo = `${node.type}`; const position = `[${node.startPosition.row},${node.startPosition.column}] - [${node.endPosition.row},${node.endPosition.column}]`; if (node.isNamed) { lines.push(`${indent}(${nodeInfo}) ${position}`); } else { // For unnamed nodes, show the literal text in quotes const text = node.text.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); lines.push(`${indent}"${text}" ${position}`); } // Format children if (node.children && node.children.length > 0) { for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; const isLast = i === node.children.length - 1; const childIndent = indent + (isLast ? ' ' : ' '); lines.push(this.formatParseTree(child, childIndent)); } } return lines.join('\n'); } filter(...queryProps: QueryPropsType[]) { const result: LspDocument[] = []; for (const queryProp of queryProps) { const filteredDocs = this.filterHelper(queryProp); result.push(...filteredDocs); } // Remove duplicates const uniqueResults = Array.from(new Set(result.map(doc => doc.uri))) .map(uri => result.find(doc => doc.uri === uri)!) .filter(Boolean); return uniqueResults; } private filterHelper( queryProp: Query | QueryConfig | string | unknown, ): LspDocument[] { // Trigger lazy registration if configured if (this._lazyRegister) { this._lazyRegister(); this._lazyRegister = undefined; // Clear after first use } // if (!this.initialized) { // this.initialize(); // } if (typeof queryProp === 'string') { return Query.create().withName(queryProp).execute(this.documents) || Query.create().withPath(queryProp).execute(this.documents); } else if (QueryConfig.is(queryProp)) { return QueryConfig.to(queryProp).execute(this.documents); } else if (Query.is(queryProp)) { return queryProp.execute(this.documents); } else { return this.documents; } } find(...queryProps: QueryPropsType[]) { for (const queryProp of queryProps) { const filteredDocs = this.filterHelper(queryProp); if (filteredDocs.length > 0) { return filteredDocs[0]; } } return undefined; } /** * Utility object providing force methods for non-standard workspace operations * Supports both sync and async initialization patterns */ get force(): TestWorkspaceForceUtil { const remove = () => { this.initialized = false; this._documents = []; this._uniqDocuments.clear(); this.files = []; if (fs.existsSync(this.workspacePath)) { fs.rmSync(this.workspacePath, { recursive: true, force: true }); } return this; }; const initializeSync = () => { return this.forceInitializeSync(); }; const snapshot = () => { if (!this.initialized) { this.forceInitializeSync(); } return this.writeSnapshot(); }; const inspect = () => { this._inspecting = true; return this; }; const reset = () => { this.initialized = false; this._documents = []; this._uniqDocuments.clear(); return this; }; const overwrite = () => { if (fs.existsSync(this.workspacePath)) { fs.rmSync(this.workspacePath, { recursive: true, force: true }); } return reset().forceInitializeSync(); }; return { remove, initialize: initializeSync, // Default to sync for convenience initializeSync, // Explicit sync method // initializeAsync, // Explicit async method snapshot, inspect, reset, overwrite, }; } edit( // queryProp: Query | QueryConfig | string | unknown, // content: string | string[] | ((doc: LspDocument) => string | string[]) = '', searchPath: string, newContent: string | string[], ) { if (!this.initialized) { throw new Error('Workspace must be initialized before editing files'); } const doc = this.find(searchPath); if (!doc) { throw new Error(`Document not found: ${searchPath}`); } const content = Array.isArray(newContent) ? newContent.join('\n') : newContent; const filePath = uriToPath(doc.uri); // Update file on disk fs.writeFileSync(filePath, content, 'utf8'); // Update document in memory and trigger re-analysis documents.applyChanges(doc.uri, [{ text: content }]); // Update our local document reference const docIndex = this._documents.findIndex(d => d.uri === doc.uri); if (docIndex !== -1) { const updatedDoc = documents.getDocument(doc.uri) || LspDocument.createFromUri(doc.uri); this._documents[docIndex] = updatedDoc; // if (this._config.autoAnalyze) { // analyzer.analyze(updatedDoc); // } } // ) } static create(name: { name: string; }, isCurrent?: boolean, config?: Record): TestWorkspace; static create(name: string, isCurrent?: boolean, config?: Record): TestWorkspace; static create(name: string | { name: string; } = generateRandomWorkspaceName(), isCurrent: boolean = true, config: Record = {}): TestWorkspace { // static create( // name: string = generateRandomWorkspaceName(), // isCurrent: boolean = true, // config: Record = {}, // ): TestWorkspace { if (typeof name === 'object' && name.name) { return new TestWorkspace(name.name, isCurrent, config); } if (typeof name !== 'string') { throw new Error('Invalid workspace name'); } return new TestWorkspace(name, isCurrent, config); } static createSingleFile( identifierName = generateRandomWorkspaceName(), content: string = `# ${identifierName} file content`, ): TestWorkspace { logger.setSilent(); return new TestWorkspace(identifierName, true).add( TestFile.script(identifierName, content), ); } /** * Force delete workspace files and reset state * @deprecated Use forceUtil().remove() instead for better API consistency */ forceDelete() { if (fs.existsSync(this.workspacePath)) { fs.rmdirSync(this.workspacePath, { recursive: true }); } this.initialized = false; this._documents = []; this._uniqDocuments.clear(); this.files = []; } } export class DefaultTestWorkspaces { /** * Creates a basic fish function workspace */ static basicFunctions(): TestWorkspace { return TestWorkspace.create('basic_functions') .add( TestFile.function('greet', ` function greet echo "Hello, $argv[1]!" end`), TestFile.function('add', ` function add math $argv[1] + $argv[2] end`), TestFile.completion('greet', ` complete -c greet -a "(ls)" complete -c greet -l help -d "Show help"`), ); } /** * Creates a workspace with complex function interactions */ static complexFunctions(): TestWorkspace { return TestWorkspace.create('complex_functions') .add( TestFile.function('main', ` function main set -l result (helper_func $argv) process_result $result end`), TestFile.function('helper_func', ` function helper_func echo "Processing: $argv" end`), TestFile.function('process_result', ` function process_result if test -n "$argv[1]" echo "Result: $argv[1]" else echo "No result" end end`), TestFile.config(` set -g my_global_var "default_value" source (dirname (status --current-filename))/functions/main.fish`), ); } /** * Creates a workspace with configuration and event handlers */ static configAndEvents(): TestWorkspace { return TestWorkspace.create('config_and_events') .add( TestFile.config(` set -g fish_greeting "Welcome to test workspace!" set -gx PATH $PATH /usr/local/test/bin`), TestFile.confd('setup', ` function setup_test_env --on-event fish_prompt if not set -q test_env_loaded set -g test_env_loaded true echo "Test environment loaded" end end`), TestFile.confd('cleanup', ` function cleanup_test_env --on-event fish_exit echo "Cleaning up test environment" end`), ); } /** * Creates a workspace that simulates a real project structure */ static projectWorkspace(): TestWorkspace { return TestWorkspace.create('project_workspace') .add( // Main project functions TestFile.function('build', ` function build echo "Building project..." if test -f Makefile make else if test -f package.json npm run build else echo "No build system found" return 1 end end`), TestFile.function('test', ` function test echo "Running tests..." if test -f package.json npm test else if test -f Cargo.toml cargo test else echo "No test framework found" return 1 end end`), TestFile.function('deploy', ` function deploy build if test $status -eq 0 echo "Deploying..." # Deployment logic here else echo "Build failed, cannot deploy" return 1 end end`), // Project completions TestFile.completion('build', ` complete -c build -l verbose -d "Enable verbose output" complete -c build -l clean -d "Clean before building"`), TestFile.completion('deploy', ` complete -c deploy -l staging -d "Deploy to staging" complete -c deploy -l production -d "Deploy to production"`), // Project configuration TestFile.config(` # Project-specific configuration set -gx PROJECT_ROOT (dirname (status --current-filename)) set -gx PROJECT_NAME "fish-test-project" # Add project bin to PATH set -gx PATH $PROJECT_ROOT/bin $PATH`), // Scripts (non-autoloaded) TestFile.script('install', `#!/usr/bin/env fish # Installation script for the project echo "Installing project dependencies..." if test -f package.json npm install else if test -f Cargo.toml cargo build end echo "Project installed successfully!"`), ); } } ================================================ FILE: tests/workspaces/embedded-functions-resolution/functions/my_test.fish ================================================ function my_test fish_add_path $__fish_data_dir echo "Embedded function executed" end ================================================ FILE: tests/workspaces/embedded-functions-resolution/functions/other_test.fish ================================================ function other_test fish_add_path $__fish_data_dir echo "other test function executed" end ================================================ FILE: tests/workspaces/embedded-functions-resolution/test_script.fish ================================================ #!/usr/bin/env fish source functions/my_test.fish source functions/other_test.fish my_test other_test funced my_test alias f=my_test ================================================ FILE: tests/workspaces/example_test_src/completions/abbr.fish ================================================ # "add" is implicit. set __fish_abbr_not_add_cond 'not __fish_seen_subcommand_from -a --add' set __fish_abbr_add_cond 'not __fish_seen_subcommand_from -q --query --rename -e --erase -s --show -l --list -h --help' complete -c abbr -f complete -c abbr -f -n $__fish_abbr_not_add_cond -s a -l add -d 'Add abbreviation' complete -c abbr -f -n $__fish_abbr_not_add_cond -s q -l query -d 'Check if an abbreviation exists' complete -c abbr -f -n $__fish_abbr_not_add_cond -l rename -d 'Rename an abbreviation' -xa '(abbr --list)' complete -c abbr -f -n $__fish_abbr_not_add_cond -s e -l erase -d 'Erase abbreviation' -xa '(abbr --list)' complete -c abbr -f -n $__fish_abbr_not_add_cond -s s -l show -d 'Print all abbreviations' complete -c abbr -f -n $__fish_abbr_not_add_cond -s l -l list -d 'Print all abbreviation names' complete -c abbr -f -n $__fish_abbr_not_add_cond -s h -l help -d Help complete -c abbr -f -n $__fish_abbr_add_cond -s p -l position -a 'command anywhere' -d 'Expand only as a command, or anywhere' -x complete -c abbr -f -n $__fish_abbr_add_cond -s f -l function -d 'Treat expansion argument as a fish function' -xa '(functions)' complete -c abbr -f -n $__fish_abbr_add_cond -s r -l regex -d 'Match a regular expression' -x complete -c abbr -f -n $__fish_abbr_add_cond -l set-cursor -d 'Position the cursor at % post-expansion' ================================================ FILE: tests/workspaces/example_test_src/completions/alias.fish ================================================ complete -c alias -s h -l help -d 'Show help and exit' complete -c alias -s s -l save -d 'Automatically funcsave the alias' ================================================ FILE: tests/workspaces/example_test_src/completions/cd.fish ================================================ complete -c cd -a "(__fish_complete_cd)" complete -c cd -s h -l help -d 'Display help and exit' ================================================ FILE: tests/workspaces/example_test_src/completions/cdh.fish ================================================ function __fish_cdh_args set -l all_dirs $dirprev $dirnext set -l uniq_dirs # This next bit of code doesn't do anything useful at the moment since the fish pager always # sorts, and eliminates duplicate, entries. But we do this to mimic the modal behavor of `cdh` # and in hope that the fish pager behavior will be changed to preserve the order of entries. for dir in $all_dirs[-1..1] if not contains $dir $uniq_dirs set uniq_dirs $uniq_dirs $dir end end for dir in $uniq_dirs set -l home_dir (string match -r "$HOME(/.*|\$)" "$dir") if set -q home_dir[2] set dir "~$home_dir[2]" end echo $dir end end complete -c cdh -kxa '(__fish_cdh_args)' ================================================ FILE: tests/workspaces/example_test_src/completions/diff.fish ================================================ # Completions for diff complete -c diff -s i -l ignore-case -d "Ignore case differences" complete -c diff -l ignore-file-name-case -d "Ignore case when comparing file names" complete -c diff -l no-ignore-file-name-case -d "Consider case when comparing file names" complete -c diff -s E -l ignore-tab-expansion -d "Ignore changes due to tab expansion" complete -c diff -s b -l ignore-space-change -d "Ignore changes in the amount of white space" complete -c diff -s w -l ignore-all-space -d "Ignore all white space" complete -c diff -s B -l ignore-blank-lines -d "Ignore changes whose lines are all blank" complete -c diff -s I -l ignore-matching-lines -x -d "Ignore changes whose lines match the REGEX" complete -c diff -s a -l text -d "Treat all files as text" complete -c diff -s r -l recursive -d "Recursively compare subdirectories" complete -c diff -s N -l new-file -d "Treat absent files as empty" complete -c diff -s C -l context -x -d "Output NUM lines of copied context" complete -c diff -s c -d "Output 3 lines of copied context" complete -c diff -s U -x -d "Output NUM lines of unified context" complete -c diff -s u -l unified -d "Output NUM lines of unified context (default 3)" complete -c diff -s q -l brief -d "Output only whether the files differ" complete -c diff -l normal -d "Output a normal diff" complete -c diff -s y -l side-by-side -d "Output in two columns" complete -c diff -s W -l width -x -d "Output at most NUM print columns" complete -c diff -s d -l minimal -d "Try to find a smaller set of changes" complete -c diff -l from-file -r -d "Compare FILE1 to all operands" complete -c diff -l to-file -r -d "Compare FILE2 to all operands" complete -c diff -s l -l paginate -d "Pass the output through 'pr'" complete -c diff -s v -l version -d "Display version and exit" complete -c diff -l help -d "Display help and exit" complete -c diff -l color -d "Colorize the output" ================================================ FILE: tests/workspaces/example_test_src/completions/fish_add_path.fish ================================================ complete -c fish_add_path -s a -l append -d 'Add path to the end' complete -c fish_add_path -s p -l prepend -d 'Add path to the front (default)' complete -c fish_add_path -s g -l global -d 'Use a global $fish_user_paths' complete -c fish_add_path -s U -l universal -d 'Use a universal $fish_user_paths (default)' complete -c fish_add_path -s P -l path -d 'Update $PATH directly' complete -c fish_add_path -s m -l move -d 'Move path to the front or back' complete -c fish_add_path -s v -l verbose -d 'Print the set command used' complete -c fish_add_path -s n -l dry-run -d 'Print the set command without executing it' complete -c fish_add_path -s h -l help -d 'Display help and exit' ================================================ FILE: tests/workspaces/example_test_src/config.fish ================================================ # This file does some internal fish setup. # It is not recommended to remove or edit it. # # Set default field separators # set -g IFS \n\ \t set -qg __fish_added_user_paths or set -g __fish_added_user_paths # # Create the default command_not_found handler # function __fish_default_command_not_found_handler printf (_ "fish: Unknown command: %s\n") (string escape -- $argv[1]) >&2 end if not status --is-interactive # Hook up the default as the command_not_found handler # if we are not interactive to avoid custom handlers. function fish_command_not_found --on-event fish_command_not_found __fish_default_command_not_found_handler $argv end end # # Set default search paths for completions and shellscript functions # unless they already exist # # __fish_data_dir, __fish_sysconf_dir, __fish_help_dir, __fish_bin_dir # are expected to have been set up by read_init from fish.cpp # Grab extra directories (as specified by the build process, usually for # third-party packages to ship completions &c. set -l __extra_completionsdir set -l __extra_functionsdir set -l __extra_confdir if path is -f -- $__fish_data_dir/__fish_build_paths.fish source $__fish_data_dir/__fish_build_paths.fish end # Compute the directories for vendor configuration. We want to include # all of XDG_DATA_DIRS, as well as the __extra_* dirs defined above. set -l xdg_data_dirs if set -q XDG_DATA_DIRS set --path xdg_data_dirs $XDG_DATA_DIRS set xdg_data_dirs (string replace -r '([^/])/$' '$1' -- $xdg_data_dirs)/fish else set xdg_data_dirs $__fish_data_dir end set -g __fish_vendor_completionsdirs set -g __fish_vendor_functionsdirs set -g __fish_vendor_confdirs # Don't load vendor directories when running unit tests if not set -q FISH_UNIT_TESTS_RUNNING set __fish_vendor_completionsdirs $__fish_user_data_dir/vendor_completions.d $xdg_data_dirs/vendor_completions.d set __fish_vendor_functionsdirs $__fish_user_data_dir/vendor_functions.d $xdg_data_dirs/vendor_functions.d set __fish_vendor_confdirs $__fish_user_data_dir/vendor_conf.d $xdg_data_dirs/vendor_conf.d # Ensure that extra directories are always included. if not contains -- $__extra_completionsdir $__fish_vendor_completionsdirs set -a __fish_vendor_completionsdirs $__extra_completionsdir end if not contains -- $__extra_functionsdir $__fish_vendor_functionsdirs set -a __fish_vendor_functionsdirs $__extra_functionsdir end if not contains -- $__extra_confdir $__fish_vendor_confdirs set -a __fish_vendor_confdirs $__extra_confdir end end # Set up function and completion paths. Make sure that the fish # default functions/completions are included in the respective path. if not set -q fish_function_path set fish_function_path $__fish_config_dir/functions $__fish_sysconf_dir/functions $__fish_vendor_functionsdirs $__fish_data_dir/functions else if not contains -- $__fish_data_dir/functions $fish_function_path set -a fish_function_path $__fish_data_dir/functions end if not set -q fish_complete_path set fish_complete_path $__fish_config_dir/completions $__fish_sysconf_dir/completions $__fish_vendor_completionsdirs $__fish_data_dir/completions $__fish_cache_dir/generated_completions else if not contains -- $__fish_data_dir/completions $fish_complete_path set -a fish_complete_path $__fish_data_dir/completions end # Add a handler for when fish_user_path changes, so we can apply the same changes to PATH function __fish_reconstruct_path -d "Update PATH when fish_user_paths changes" --on-variable fish_user_paths # Deduplicate $fish_user_paths # This should help with people appending to it in config.fish set -l new_user_path for path in (string split : -- $fish_user_paths) if not contains -- $path $new_user_path set -a new_user_path $path end end if test (count $new_user_path) -lt (count $fish_user_paths) # This will end up calling us again, so we return set fish_user_paths $new_user_path return end set -l local_path $PATH for x in $__fish_added_user_paths set -l idx (contains --index -- $x $local_path) and set -e local_path[$idx] end set -g __fish_added_user_paths if set -q fish_user_paths # Explicitly split on ":" because $fish_user_paths might not be a path variable, # but $PATH definitely is. for x in (string split ":" -- $fish_user_paths[-1..1]) if set -l idx (contains --index -- $x $local_path) set -e local_path[$idx] else set -ga __fish_added_user_paths $x end set -p local_path $x end end set -xg PATH $local_path end # # Launch debugger on SIGTRAP # function fish_sigtrap_handler --on-signal TRAP --no-scope-shadowing --description "TRAP handler: debug prompt" breakpoint end # # When a prompt is first displayed, make sure that interactive # mode-specific initializations have been performed. # This includes a `read` prompt, hence the fish_read event. # This handler removes itself after it is first called. # function __fish_on_interactive --on-event fish_prompt --on-event fish_read # We erase this *first* so it can't be called again, # e.g. if fish_greeting calls "read". functions -e __fish_on_interactive __fish_config_interactive end # Set the locale if it isn't explicitly set. Allowing the lack of locale env vars to imply the # C/POSIX locale causes too many problems. Do this before reading the snippets because they might be # in UTF-8 (with non-ASCII characters). not set -q LANG # (fast path - no need to load the file if we have $LANG) and __fish_set_locale # # Some things should only be done for login terminals # This used to be in etc/config.fish - keep it here to keep the semantics # if status --is-login if command -sq /usr/libexec/path_helper # Adapt construct_path from the macOS /usr/libexec/path_helper # executable for fish; see # https://opensource.apple.com/source/shell_cmds/shell_cmds-203/path_helper/path_helper.c.auto.html . function __fish_macos_set_env -d "set an environment variable like path_helper does (macOS only)" set -l result # Populate path according to config files for path_file in $argv[2] $argv[3]/* for entry in (string split : Admin > Extra (e.g. vendors) > Fish) by basically doing "basename". set -l sourcelist for file in $__fish_config_dir/conf.d/*.fish $__fish_sysconf_dir/conf.d/*.fish $__fish_vendor_confdirs/*.fish set -l basename (string replace -r '^.*/' '' -- $file) contains -- $basename $sourcelist and continue set sourcelist $sourcelist $basename # Also skip non-files or unreadable files. # This allows one to use e.g. symlinks to /dev/null to "mask" something (like in systemd). test -f $file -a -r $file and source $file end ================================================ FILE: tests/workspaces/example_test_src/functions/abbr.fish ================================================ # This file intentionally left blank. # This is provided to overwrite existing abbr.fish files, so that any abbr # function retained from past fish releases does not override the abbr builtin. ================================================ FILE: tests/workspaces/example_test_src/functions/alias.fish ================================================ function alias --description 'Creates a function wrapping a command' set -l options h/help s/save argparse -n alias --max-args=2 $options -- $argv or return if set -q _flag_help __fish_print_help alias return 0 end set -l name set -l body if not set -q argv[1] # Print the known aliases. for func in (functions -n) set -l output (functions $func | string match -r -- "^function .* --description (?:'alias (.*)'|alias\\\\ (.*))\$") if set -q output[2] set output (string replace -r -- '^'(string escape --style=regex -- $func)'[= ]' '' $output[2]) echo alias $func (string escape -- $output[1]) end end return 0 else if not set -q argv[2] # Alias definition of the form "name=value". set -l tmp (string split -m 1 "=" -- $argv) "" set name $tmp[1] set body $tmp[2] else # Alias definition of the form "name value". set name $argv[1] set body $argv[2] end # sanity check if test -z "$name" printf ( _ "%s: name cannot be empty\n") alias >&2 return 1 else if test -z "$body" printf ( _ "%s: body cannot be empty\n") alias >&2 return 1 end # Extract the first command from the body. printf '%s\n' $body | read -l --list words set -l first_word $words[1] set -l last_word $words[-1] # Prevent the alias from immediately running into an infinite recursion if # $body starts with the same command as $name. if test $first_word = $name if contains $name (builtin --names) set body "builtin $body" else set body "command $body" end end set -l cmd_string (string escape -- "alias $argv") # Do not define wrapper completion if we have "alias foo 'foo xyz'" or "alias foo 'sudo foo'" # This is to prevent completions from recursively calling themselves (#7389). # The latter will have rare false positives but it's more important to # prevent recursion for this high-level command. set -l wraps if test $first_word != $name; and test $last_word != $name set wraps --wraps (string escape -- $body) end # The function definition in split in two lines to ensure that a '#' can be put in the body. echo "function $name $wraps --description $cmd_string"\n" $body \$argv"\n"end" | source if set -q _flag_save funcsave $name end end ================================================ FILE: tests/workspaces/example_test_src/functions/cd.fish ================================================ # # Wrap the builtin cd command to maintain directory history. # function cd --description "Change directory" set -l MAX_DIR_HIST 25 if set -q argv[2]; and begin set -q argv[3] or not test "$argv[1]" = -- end printf "%s\n" (_ "Too many args for cd command") >&2 return 1 end # Skip history in subshells. if status --is-command-substitution builtin cd $argv return $status end # Avoid set completions. set -l previous $PWD if test "$argv" = - if test "$__fish_cd_direction" = next nextd else prevd end return $status end builtin cd $argv set -l cd_status $status if test $cd_status -eq 0 -a "$PWD" != "$previous" set -q dirprev or set -l dirprev set -q dirprev[$MAX_DIR_HIST] and set -e dirprev[1] # If dirprev, dirnext, __fish_cd_direction # are set as universal variables, honor their scope. set -U -q dirprev and set -U -a dirprev $previous or set -g -a dirprev $previous set -U -q dirnext and set -U -e dirnext or set -e dirnext set -U -q __fish_cd_direction and set -U __fish_cd_direction prev or set -g __fish_cd_direction prev end return $cd_status end ================================================ FILE: tests/workspaces/example_test_src/functions/cdh.fish ================================================ # Provide a menu of the directories recently navigated to and ask the user to # choose one to make the new current working directory (cwd). function cdh --description "Menu based cd command" # See if we've been invoked with an argument. Presumably from the `cdh` completion script. # If we have just treat it as `cd` to the specified directory. if set -q argv[1] cd $argv return end if set -q argv[2] echo (_ "cdh: Expected zero or one arguments") >&2 return 1 end set -l all_dirs $dirprev $dirnext if not set -q all_dirs[1] echo (_ 'No previous directories to select. You have to cd at least once.') >&2 return 0 end # Reverse the directories so the most recently visited is first in the list. # Also, eliminate duplicates; i.e., we only want the most recent visit to a # given directory in the selection list. set -l uniq_dirs for dir in $all_dirs[-1..1] if not contains $dir $uniq_dirs set -a uniq_dirs $dir end end set -l letters 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 set -l dirc (count $uniq_dirs) if test $dirc -gt (count $letters) set -l msg (_ 'This should not happen. Have you changed the cd function?') printf "$msg\n" >&2 set -l msg (_ 'There are %s unique dirs in your history but I can only handle %s') printf "$msg\n" $dirc (count $letters) >&2 return 1 end # Print the recent directories, oldest to newest. Since we previously # reversed the list, making the newest entry the first item in the array, # we count down rather than up. for i in (seq $dirc -1 1) set -l dir $uniq_dirs[$i] set -l label_color normal set -q fish_color_cwd and set label_color $fish_color_cwd set -l dir_color_reset (set_color normal) set -l dir_color if test "$dir" = "$PWD" set dir_color (set_color $fish_color_history_current) end set -l home_dir (string match -r "^$HOME(/.*|\$)" "$dir") if set -q home_dir[2] set dir "~$home_dir[2]" end printf '%s %s %2d) %s %s%s%s\n' (set_color $label_color) $letters[$i] $i (set_color normal) $dir_color $dir $dir_color_reset end # Ask the user which directory from their history they want to cd to. set -l msg (_ 'Select directory by letter or number: ') read -l -p "echo '$msg'" choice if test -z "$choice" return 0 else if string match -q -r '^[a-z]$' $choice # Convert the letter to an index number. set choice (contains -i $choice $letters) end set -l msg (_ 'Error: expected a number between 1 and %d or letter in that range, got "%s"') if string match -q -r '^\d+$' $choice if test $choice -ge 1 -a $choice -le $dirc cd $uniq_dirs[$choice] return else printf "$msg\n" $dirc $choice >&2 return 1 end else printf "$msg\n" $dirc $choice >&2 return 1 end end ================================================ FILE: tests/workspaces/example_test_src/functions/contains_seq.fish ================================================ function contains_seq --description 'Return true if array contains a sequence' set -l printnext switch $argv[1] case --printnext set printnext[1] 1 set -e argv[1] end set -l pattern set -l string set -l dest pattern for i in $argv if test "$i" = -- set dest string continue end set $dest $$dest $i end set -l nomatch 1 set -l i 1 for s in $string if set -q printnext[2] return 0 end if test "$s" = "$pattern[$i]" set -e nomatch[1] set i (math $i + 1) if not set -q pattern[$i] if set -q printnext[1] set printnext[2] 1 continue end return 0 end else if not set -q nomatch[1] set nomatch 1 set i 1 end end end if set -q printnext[1] echo '' end set -q printnext[2] end ================================================ FILE: tests/workspaces/example_test_src/functions/diff.fish ================================================ # Use colours in diff output, if supported if command -vq diff; and command diff --color=auto /dev/null{,} >/dev/null 2>&1 function diff command diff --color=auto $argv end end ================================================ FILE: tests/workspaces/example_test_src/functions/dirh.fish ================================================ function dirh --description "Print the current directory history (the prev and next lists)" set -l options h/help argparse -n dirh --max-args=0 $options -- $argv or return if set -q _flag_help __fish_print_help dirh return 0 end set -l dirc (count $dirprev) if test $dirc -gt 0 set -l dirprev_rev $dirprev[-1..1] # This can't be (seq $dirc -1 1) because of BSD. set -l dirnum (seq 1 $dirc) for i in $dirnum[-1..1] printf '%2d) %s\n' $i $dirprev_rev[$i] end end echo (set_color $fish_color_history_current)' ' $PWD(set_color normal) set -l dirc (count $dirnext) if test $dirc -gt 0 set -l dirnext_rev $dirnext[-1..1] for i in (seq $dirc) printf '%2d) %s\n' $i $dirnext_rev[$i] end end echo end ================================================ FILE: tests/workspaces/example_test_src/functions/dirs.fish ================================================ function dirs --description 'Print directory stack' set -l options h/help c argparse -n dirs --max-args=0 $options -- $argv or return if set -q _flag_help __fish_print_help dirs return 0 end if set -q _flag_c # Clear directory stack. set -e -g dirstack return 0 end # Replace $HOME with ~. string replace -r '^'"$HOME"'($|/)' '~$1' -- $PWD $dirstack | string join " " return 0 end ================================================ FILE: tests/workspaces/example_test_src/functions/down-or-search.fish ================================================ function down-or-search -d "search forward or move down 1 line" # If we are already in search mode, continue if commandline --search-mode commandline -f history-search-forward return end # If we are navigating the pager, then up always navigates if commandline --paging-mode commandline -f down-line return end # We are not already in search mode. # If we are on the bottom line, start search mode, # otherwise move down set -l lineno (commandline -L) set -l line_count (count (commandline)) switch $lineno case $line_count commandline -f history-search-forward case '*' commandline -f down-line end end ================================================ FILE: tests/workspaces/example_test_src/functions/edit_command_buffer.fish ================================================ function edit_command_buffer --description 'Edit the command buffer in an external editor' set -l tmpdir (__fish_mktemp_relative -d fish) or return 1 set -l f $tmpdir/command-line.fish command touch $f or return 1 set -l editor (__fish_anyeditor) or return 1 set -l indented_lines (commandline -b | __fish_indent --only-indent) string join -- \n $indented_lines >$f set -l offset (commandline --cursor) # compute cursor line/column set -l lines (commandline)\n set -l line 1 while test $offset -ge (string length -- $lines[1]) set offset (math $offset - (string length -- $lines[1])) set line (math $line + 1) set -e lines[1] end set -l indent 1 + (string length -- $indented_lines[$line]) - (string length -- $lines[1]) set -l col (math $offset + 1 + $indent) set -l editor_basename (string match -r '[^/]+$' -- $editor[1]) set -l wrapped_commands for wrap_target in (complete -- $editor_basename | string replace -rf '^complete [^/]+ --wraps (.+)$' '$1') set -l tmp string unescape -- $wrap_target | read -at tmp set -a wrapped_commands $tmp[1] end set -l found false set -l cursor_from_editor for editor_command in $editor_basename $wrapped_commands switch $editor_command case vi vim nvim if test $editor_command = vi && not set -l vi_version "$(vi --version 2>/dev/null)" if printf %s $vi_version | grep -q BusyBox break end set -a editor +{$line} $f set found true break end set cursor_from_editor (__fish_mktemp_relative fish-edit_command_buffer) set -a editor +$line "+norm! $col|" $f \ '+au VimLeave * ++once call writefile([printf("%s %s %s", shellescape(bufname()), line("."), col("."))], "'$cursor_from_editor'")' case emacs emacsclient gedit set -a editor +$line:$col $f case kak set cursor_from_editor (__fish_mktemp_relative fish-edit_command_buffer) set -a editor +$line:$col $f -e " hook -always -once global ClientClose %val{client} %{ echo -to-file $cursor_from_editor -quoting shell \ %val{buffile} %val{cursor_line} %val{cursor_column} } " case nano set -a editor +$line,$col $f case joe ee set -a editor +$line $f case code code-oss set -a editor --goto $f:$line:$col --wait case subl set -a editor $f:$line:$col --wait case micro set -a editor $f +$line:$col case helix hx set -a editor $f:$line:$col case '*' continue end set found true break end if not $found set -a editor $f end $editor set -l raw_lines (command cat $f) set -l unindented_lines (string join -- \n $raw_lines | __fish_indent --only-unindent) # Here we're checking the exit status of the editor. if test $status -eq 0 -a -s $f # Set the command to the output of the edited command and move the cursor to the # end of the edited command. commandline -r -- $unindented_lines commandline -C 999999 else echo echo (_ "Ignoring the output of your editor since its exit status was non-zero") echo (_ "or the file was empty") end if set -q cursor_from_editor[1] eval set -l pos "$(cat $cursor_from_editor)" if set -q pos[1] && test $pos[1] = $f set -l line $pos[2] set -l indent (math (string length -- "$raw_lines[$line]") - (string length -- "$unindented_lines[$line]")) set -l column (math $pos[3] - $indent) if not commandline --line $line 2>/dev/null commandline -f end-of-buffer else commandline --column $column 2>/dev/null || commandline -f end-of-line end end command rm $cursor_from_editor end command rm -r (path dirname $f) # We've probably opened something that messed with the screen. # A repaint seems in order. commandline -f repaint end ================================================ FILE: tests/workspaces/example_test_src/functions/export.fish ================================================ function export --description 'Set env variable. Alias for `set -gx` for bash compatibility.' if not set -q argv[1] set -x return 0 end for arg in $argv set -l v (string split -m 1 "=" -- $arg) set -l value switch (count $v) case 1 set value $$v[1] case 2 set value $v[2] end set -gx $v[1] $value end end ================================================ FILE: tests/workspaces/example_test_src/functions/fish_add_path.fish ================================================ function fish_add_path --description "Add paths to the PATH" # This is meant to be the easy one-stop shop to adding stuff to $PATH. # By default it'll prepend the given paths to a universal $fish_user_paths, excluding the already-included ones. # # That means it can be executed once in an interactive session, or stuffed in config.fish, # and it will do The Right Thing. # # The options: # --prepend or --append to select whether to put the new paths first or last # --global or --universal to decide whether to use a universal or global fish_user_paths # --path to set $PATH instead # --move to move existing entries instead of ignoring them # --verbose to print the set-command used # --dry-run to print the set-command without running it # We do not allow setting $PATH universally. # # It defaults to keeping $fish_user_paths or creating a universal, prepending and ignoring existing entries. argparse -x g,U -x P,U -x a,p g/global U/universal P/path p/prepend a/append h/help m/move v/verbose n/dry-run -- $argv or return if set -q _flag_help __fish_print_help fish_add_path return 0 end set -l scope $_flag_global $_flag_universal if not set -q scope[1]; and not set -q fish_user_paths set scope -U end set -l var fish_user_paths set -q _flag_path and set var PATH # $PATH should be global and set scope -g set -l mode $_flag_prepend $_flag_append set -q mode[1]; or set mode -p # Enable verbose mode if we're interactively used status current-command | string match -rq '^fish_add_path$' and isatty stdout and set -l _flag_verbose yes # To keep the order of our arguments, go through and save the ones we want to keep. set -l newpaths set -l indexes for path in $argv # Realpath allows us to canonicalize the path, which is needed for deduplication. # We could add a non-canonical version of the given path if no duplicate exists, but tbh that's a recipe for disaster. # realpath complains if a parent directory does not exist, so we silence stderr. set -l p (builtin realpath -s -- $path 2>/dev/null) # Ignore non-existing paths if not test -d "$p" # path does not exist if set -q _flag_verbose # print a message in verbose mode if test -f "$p" printf (_ "Skipping path because it is a file instead of a directory: %s\n") "$p" else printf (_ "Skipping non-existent path: %s\n") "$p" end end continue end if set -l ind (contains -i -- $p $$var) # In move-mode, we remove it from its current position and add it back. if set -q _flag_move; and not contains -- $p $newpaths set -a indexes $ind set -a newpaths $p else if set -q _flag_verbose printf (_ "Skipping already included path: %s\n") "$p" end else if not contains -- $p $newpaths # Without move, we only add it if it's not in. set -a newpaths $p end end # Ensure the variable is only set once, by constructing a new variable before. # This is to stop any handlers or anything from firing more than once. set -l newvar $$var if set -q _flag_move; and set -q indexes[1] # We remove in one step, so the indexes don't move. set -e newvar["$indexes"] end set $mode newvar $newpaths # Finally, only set if there is anything *to* set. # This saves us from setting, especially in the common case of someone putting this in config.fish # to ensure a path is in $PATH. if set -q newpaths[1]; or set -q indexes[1] if set -q _flag_verbose; or set -q _flag_n # The escape helps make it unambiguous - so you see whether an argument includes a space or something. echo (string escape -- set $scope $var $newvar) end not set -q _flag_n and set $scope $var $newvar return 0 else if set -q _flag_verbose # print a message in verbose mode printf (_ "No paths to add, not setting anything.\n") "$p" end return 1 end end ================================================ FILE: tests/workspaces/incorrect-permissions-indexing/file.fish ================================================ # Here we test if fish-lsp is erroring out when reading this workspace # since it contains a folder that is not readable by the current user function create_unreadable_folder -d 'helper so we don\'t have to ship root privilege folder' mkdir unreadable_folder touch unreadable_folder/readable_file.fish chmod 000 unreadable_folder/readable_file.fish chmod 000 unreadable_folder return $status end function remove_unreadable_folder -d 'helper to remove the unreadable folder' if test -d unreadable_folder && test -r unreadable_folder rm -ri unreadable_folder/ return $status else if test -d unreadable_folder echo "Removing folder requires root privilege" >&2 echo "You can run 'sudo rm -rf unreadable_folder/' or 'sudo fish file.fish --remove'" >&2 return 1 end echo "folder 'unreadable_folder/' does not exist" >&2 return 1 end argparse remove create h/help -- $argv or return if set -q _flag_help echo "file.fish [OPTIONS]" echo "" echo "This file is used to test if fish-lsp is erroring out when reading this workspace" echo "" echo "OPTIONS:" echo " -h, --help Show this message" echo " --create Create a folder that is not readable by the current user" ehco " --remove Remove the folder that is not readable by the current user" echo "" echo "USAGE:" echo " >_ fish file.fish --create" echo " >_ fish-lsp info --time-startup --no-warning --use-workspace ." return 0 end if set -q _flag_create create_unreadable_folder echo "Created unreadable_folder" return 0 end if set -q _flag_remove remove_unreadable_folder return $status end echo "This is a normal file" echo "You can run 'fish file.fish --create-unreadable' to create a folder that is not readable by the current user" ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/basic.fish ================================================ #!/usr/bin/env fish # Basic fish script with common patterns function greet set -l name "World" echo "Hello, $name" end greet ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/commands.fish ================================================ #!/usr/bin/env fish # Builtin commands and user functions echo "builtin" set foo bar read -l input test -f file.txt function custom_cmd echo "custom" end custom_cmd ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/completions/deployctl.fish ================================================ complete -c deployctl -s s -l stage -d 'Stage to target' complete -c deployctl -s r -l region -d 'Region to deploy' complete -c deployctl -s f -l force -d 'Skip confirmation' complete -c deployctl -l dry-run -d 'Preview actions' complete -c deployctl -l retries -d 'Retry count' ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/completions/source_fish.fish ================================================ complete -c source_fish -s f -l force -d 'Force reload of fish config' complete -c source_fish -s h -l help -d 'Show help' complete -c source_fish -s q -l quiet -d 'Silence' complete -c source_fish -l no-parse -d 'Skip parsing check' complete -c source_fish -l sleep -d 'Add sleep delay' complete -c source_fish -s e -l edit -d 'Edit ~/.config/fish/{functions,completions}/source_fish.fish files' ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/diagnostics.fish ================================================ #!/usr/bin/env fish # Diagnostic comment handling # @fish-lsp-disable echo "disabled" # @fish-lsp-enable # @fish-lsp-disable-next-line 4004 echo "next line disabled" # Regular comment echo "normal" ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/functions.fish ================================================ #!/usr/bin/env fish # Function definitions and calls function my_func echo "in my_func" end function another_func echo "in another_func" my_func end my_func another_func ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/keywords.fish ================================================ #!/usr/bin/env fish # Keyword usage if test -f /tmp/file echo "exists" else echo "not found" end for item in a b c echo $item end while true break end switch $value case 1 echo "one" case 2 echo "two" case '*' echo "other" end ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/mixed.fish ================================================ #!/usr/bin/env fish # Mixed features function process --argument-names input_file output_file set -l temp_var (cat $input_file) if test -n "$temp_var" echo $temp_var > $output_file end end set -g DATA_DIR /var/data process -- $DATA_DIR/input.txt $DATA_DIR/output.txt ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/operators.fish ================================================ #!/usr/bin/env fish # Operator usage read -- my_var echo -- hello set -- args a b c ================================================ FILE: tests/workspaces/semantic-tokens-simple-workspace/variables.fish ================================================ #!/usr/bin/env fish # Variable definitions and expansions set -l local_var "local" set -g global_var "global" set -U universal_var "universal" set -x exported_var "exported" echo $local_var echo $global_var echo $universal_var echo $exported_var echo $PATH $HOME $USER ================================================ FILE: tests/workspaces/workspace_1/fish/completions/exa.fish ================================================ #"Fossies" - the Fresh Open Source Software Archive #Member "exa-0.10.1/completions/completions.fish" (12 Apr 2021, 4846 Bytes) of package /linux/misc/exa-0.10.1.tar.gz: #As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Fish source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. # Meta-stuff complete -c exa -s 'v' -l 'version' -d "Show version of exa" complete -c exa -s '?' -l 'help' -d "Show list of command-line options" # Display options complete -c exa -s '1' -l 'oneline' -d "Display one entry per line" complete -c exa -s 'l' -l 'long' -d "Display extended file metadata as a table" complete -c exa -s 'G' -l 'grid' -d "Display entries in a grid" complete -c exa -s 'x' -l 'across' -d "Sort the grid across, rather than downwards" complete -c exa -s 'R' -l 'recurse' -d "Recurse into directories" complete -c exa -s 'T' -l 'tree' -d "Recurse into directories as a tree" complete -c exa -s 'F' -l 'classify' -d "Display type indicator by file names" complete -c exa -l 'color' -d "When to use terminal colours" complete -c exa -l 'colour' -d "When to use terminal colours" complete -c exa -l 'color-scale' -d "Highlight levels of file sizes distinctly" complete -c exa -l 'colour-scale' -d "Highlight levels of file sizes distinctly" complete -c exa -l 'icons' -d "Display icons" complete -c exa -l 'no-icons' -d "Don't display icons" # Filtering and sorting options complete -c exa -l 'group-directories-first' -d "Sort directories before other files" complete -c exa -l 'git-ignore' -d "Ignore files mentioned in '.gitignore'" complete -c exa -s 'a' -l 'all' -d "Show hidden and 'dot' files" complete -c exa -s 'd' -l 'list-dirs' -d "List directories like regular files" complete -c exa -s 'L' -l 'level' -d "Limit the depth of recursion" -a "1 2 3 4 5 6 7 8 9" complete -c exa -s 'r' -l 'reverse' -d "Reverse the sort order" complete -c exa -s 's' -l 'sort' -x -d "Which field to sort by" -a " accessed\t'Sort by file accessed time' age\t'Sort by file modified time (newest first)' changed\t'Sort by changed time' created\t'Sort by file modified time' date\t'Sort by file modified time' ext\t'Sort by file extension' Ext\t'Sort by file extension (uppercase first)' extension\t'Sort by file extension' Extension\t'Sort by file extension (uppercase first)' filename\t'Sort by filename' Filename\t'Sort by filename (uppercase first)' inode\t'Sort by file inode' modified\t'Sort by file modified time' name\t'Sort by filename' Name\t'Sort by filename (uppercase first)' newest\t'Sort by file modified time (newest first)' none\t'Do not sort files at all' oldest\t'Sort by file modified time' size\t'Sort by file size' time\t'Sort by file modified time' type\t'Sort by file type' " complete -c exa -s 'I' -l 'ignore-glob' -d "Ignore files that match these glob patterns" -r complete -c exa -s 'D' -l 'only-dirs' -d "List only directories" # Long view options complete -c exa -s 'b' -l 'binary' -d "List file sizes with binary prefixes" complete -c exa -s 'B' -l 'bytes' -d "List file sizes in bytes, without any prefixes" complete -c exa -s 'g' -l 'group' -d "List each file's group" complete -c exa -s 'h' -l 'header' -d "Add a header row to each column" complete -c exa -s 'h' -l 'links' -d "List each file's number of hard links" complete -c exa -s 'g' -l 'group' -d "List each file's inode number" complete -c exa -s 'S' -l 'blocks' -d "List each file's number of filesystem blocks" complete -c exa -s 't' -l 'time' -x -d "Which timestamp field to list" -a " modified\t'Display modified time' changed\t'Display changed time' accessed\t'Display accessed time' created\t'Display created time' " complete -c exa -s 'm' -l 'modified' -d "Use the modified timestamp field" complete -c exa -s 'n' -l 'numeric' -d "List numeric user and group IDs." complete -c exa -l 'changed' -d "Use the changed timestamp field" complete -c exa -s 'u' -l 'accessed' -d "Use the accessed timestamp field" complete -c exa -s 'U' -l 'created' -d "Use the created timestamp field" complete -c exa -l 'time-style' -x -d "How to format timestamps" -a " default\t'Use the default time style' iso\t'Display brief ISO timestamps' long-iso\t'Display longer ISO timestamps, up to the minute' full-iso\t'Display full ISO timestamps, up to the nanosecond' " complete -c exa -l 'no-permissions' -d "Suppress the permissions field" complete -c exa -l 'octal-permissions' -d "List each file's permission in octal format" complete -c exa -l 'no-filesize' -d "Suppress the filesize field" complete -c exa -l 'no-user' -d "Suppress the user field" complete -c exa -l 'no-time' -d "Suppress the time field" # Optional extras complete -c exa -l 'git' -d "List each file's Git status, if tracked" complete -c exa -s '@' -l 'extended' -d "List each file's extended attributes and sizes" ================================================ FILE: tests/workspaces/workspace_1/fish/config.fish ================================================ set -gx PATH $HOME/.cargo/bin $PATH function fish_user_key_bindings bind \cH backward-kill-word if os-name --is-mac bind ctrl-down down-line bind ctrl-up up-line else if os-name --is-linux bind ctrl-down down-line bind ctrl-up up-line bind ctrl-space complete end end abbr -a -g nrt 'npm run test' set -gx EDITOR nvim set -gx VISUAL nvim ================================================ FILE: tests/workspaces/workspace_1/fish/functions/func-inner.fish ================================================ function func-inner --argument-names arg1 arg2 echo "func-inner" function __inner printf "\t%s" "__inner " printf "%s\n" $argv end if set -q arg1 && set -q arg2 __inner "arg1 and arg2 are set" __inner "arg1: $arg1" __inner "arg2: $arg2" else __inner "arg1 and arg2 are not set" end end #func-inner a b ================================================ FILE: tests/workspaces/workspace_1/fish/functions/test-func.fish ================================================ function test-func set -l count 1 for arg in $argv __helper-test-func $count $arg set count (math $count + 1) end end function __helper-test-func --argument-names index arg printf "index:$index argument:$arg\n" end # $ fish test-data/fish_files/functions/test-func.fish 1 2 3 # test-func a b c ================================================ FILE: tests/workspaces/workspace_1/fish/functions/test-rename-1.fish ================================================ function test-rename-1 function test-rename-inner echo "rename this function only" end test-rename-inner end ================================================ FILE: tests/workspaces/workspace_1/fish/functions/test-rename-2.fish ================================================ function test-rename-2 -d "calls test-rename-1" test-rename-1 end ================================================ FILE: tests/workspaces/workspace_1/fish/functions/test-variable-renames.fish ================================================ function test-variable-renames if set -q PATH echo '$PATH is set to:'$PATH end echo $EDITOR fish_user_key_bindings end ================================================ FILE: tests/workspaces/workspace_semantic-tokens/test-operator-tokens.fish ================================================ #!/usr/bin/env fish # Test file for operator semantic tokens # -- operator (already in highlights as it seems) echo test -- this is after -- >> output.txt # Pipe and redirect operators ls | grep test ls >output.txt ls >>append.txt ls 2>error.txt ls &>all.txt ls | tee /output/a # Fish LSP directive comments with nested keywords # @fish-lsp-disable echo "disabled diagnostics" >&2 # @fish-lsp-enable # @fish-lsp-disable-next-line echo "next line disabled" # @fish-lsp-disable echo "enabled specific codes" echo "$( set qux b)" set --local bar --baz -- \ qux argparse h/help -- $argv or return baz a d e -g command alias arg2 --option=value\ a b # { # echo "inside block"; # echo "still inside block" # } if test $var -eq 1; and echo "var is 1"; or echo "var is not 1"; else foo end export bar=~/bas/baz set -gx qux (foo --bar baz) echo (seq 1 10) fish_add_path -o=/tmp/path -v=1 and echo "file exists" alias fa=foo alias ga='grep --color=auto' alias la 'ls -lah' abbr -a aaa ~/foo/ba/a [ -f /etc/fish/config.fish ]; and echo "config exists" foo=b b if foo else if bar else end ================================================ FILE: tsconfig.eslint.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "downlevelIteration": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true }, "include": [ "package.json", "src", "eslint.config.ts", "commitlint.config.ts", "tests", "scripts/fish-commands-scrapper.ts" ], "exclude": [ "node_modules", "release-assets", "dist", "lib", "scripts", "coverage", "vitest.config.ts" ] } ================================================ FILE: tsconfig.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "extends": [ "@tsconfig/node22/tsconfig.json", "@tsconfig/node-ts/tsconfig.json" ], "compilerOptions": { "target": "es2018", "lib": [ "ES2019.Object", "ES2022", "ESNext" ], "noEmit": false, "moduleResolution": "bundler", "declaration": true, "declarationMap": true, "incremental": true, "esModuleInterop": true, "importHelpers": false, // causes tslib dependency which might not be available on node "isolatedModules": true, "downlevelIteration": true, "sourceMap": true, "stripInternal": true, "removeComments": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": false, "isolatedDeclarations": false, "erasableSyntaxOnly": false, "outDir": "./out", "baseUrl": ".", // Base URL for module resolution "tsBuildInfoFile": ".tsbuildinfo", "types": [ "vitest/globals" ], "paths": { "@package": [ "./package.json" ], "./cli": [ "./src/cli.ts" ], "@embedded_assets/*": [ "./*" ] } }, "include": [ "package.json", "fish_files/*.fish", "src", "src/types/embedded-assets.d.ts", "tests", "eslint.config.ts" ], "exclude": [ "node_modules", "out" ] } ================================================ FILE: vitest.config.ts ================================================ import { defineConfig, Plugin } from 'vitest/config' import tsconfigPaths from 'vite-tsconfig-paths' import wasm from 'vite-plugin-wasm' import * as path from 'path' import { readFileSync } from 'fs'; // Plugin to load .fish files as string exports function fishLoader(): Plugin { return { name: 'fish-loader', enforce: 'pre', transform(code, id) { if (id.endsWith('.fish')) { const content = readFileSync(id, 'utf-8') return { code: `export default ${JSON.stringify(content)};`, map: null } } } } } export default defineConfig({ plugins: [tsconfigPaths(), wasm(), fishLoader()], test: { environment: 'node', include: ['tests/**/*.test.ts'], globals: true, setupFiles: ['tests/setup-mocks.ts'], coverage: { provider: 'v8', include: ['src/**/*.ts'], exclude: [ 'src/**/*.d.ts', 'src/**/__tests__/**', 'tests/**', 'src/**/test/**', 'src/types/**', 'src/snippets/**', 'src/documentation.ts', 'src/web.ts', 'src/utils/completions/**', ], reporter: [ ['html-spa', { 'projectRoot': './src' }], ['lcov', { 'projectRoot': './src' }], 'text', ], ignoreEmptyLines: true, reportOnFailure: true, }, testTimeout: 20_000, fileParallelism: true, hookTimeout: 60_000, teardownTimeout: 70_000, }, esbuild: { exclude: ['**/*.fish'] }, assetsInclude: ['**/*.fish', '**/*.wasm'], resolve: { alias: { '@package': path.resolve(__dirname, 'package.json'), '@embedded_assets/tree-sitter.wasm': path.resolve(__dirname, 'tree-sitter.wasm'), // '@fish_files/get-docs.fish': (path.resolve(path.join(__dirname, 'fish_files', 'get-docs.fish'))) } } })