From 5b03c4d00879de8dc06ebcbc6a8c2fe49ec712d6 Mon Sep 17 00:00:00 2001 From: nick <49458459+ndonfris@users.noreply.github.com> Date: Fri, 7 Jun 2024 05:45:22 -0500 Subject: [PATCH] fix: fixed hover doc support & improved test coverage (#33) * chore: added significant coverage to test-data/node-types.test.ts & src/utils/node-types.ts * chore: testing functions in tree-sitter.ts * chore: began writing signature.test.fish * chore: 'signature -> alias, functions' in test-data/signature.test.ts * Refactor: Update signatureHelpProvider in config.ts and import statements in server.ts Why: - The signatureHelpProvider object in config.ts has been refactored to include a new property 'workDoneProgress' and updated 'triggerCharacters'. - Additional imports related to signature handling have been added in server.ts for better functionality. Changes: - Updated signatureHelpProvider in config.ts to include 'workDoneProgress' property and update 'triggerCharacters'. - Added import statements for signature-related functions in server.ts to improve signature handling. * docs: updates to ./docs/CONTRIBUTING.md * feat: fixed test-data/formatting.test.ts * docs: fixed emoji's in Header * added to ./test-data/node-types.test.ts * feat: added src/document-highlight.ts * feat: added mini `fish-lsp.executeLine` command with `onExecuteCommandHandler` - on line ~/.config/fish/config/fish (line: 596) ```fish echo yes ``` `:CocCommand fish-lsp.executeLine $HOME/.config/fish/config.fish 595` --- Output's in popup window: ```console Executed: echo yes Output: yes ``` * fix: line correction for client `onCommandExecution(connection, line-1)` in src/server.ts * feat: parser language tests * feat: (with logging) fix status/$status + multiline \ + function hover --flags * docs: added contributing screenshots * docs: README.md features * fix: removed `{'paths': { '@utils/*': ['./src/utils/*']}}' bad resolution from tsconfig --- README.md | 4 +- docs/CONTRIBUTING.md | 197 +++++++-- package.json | 12 +- src/config.ts | 11 +- src/document-highlight.ts | 45 ++ src/executeHandler.ts | 19 + src/hover.ts | 28 +- src/server.ts | 144 +++++-- src/signature.ts | 142 +++++- src/utils/completion/pager.ts | 12 +- src/utils/completion/startup-cache.ts | 31 +- src/utils/node-types.ts | 64 +++ src/utils/snippets.ts | 2 +- src/utils/translation.ts | 23 +- src/utils/tree-sitter.ts | 13 +- test-data/fish_files/umask.fish | 2 +- test-data/formatting.test.ts | 1 - test-data/inline-parser.test.ts | 458 ++++++++++++++++++++ test-data/node-types.test.ts | 600 ++++++++++++++++++++++++-- test-data/parser.test.ts | 118 ++++- test-data/semantic-tokens.test.ts | 27 ++ test-data/signature.test.ts | 224 ++++++++++ test-data/tree-sitter.test.ts | 356 +++++++++++++++ tsconfig.json | 3 +- 24 files changed, 2365 insertions(+), 171 deletions(-) create mode 100644 src/document-highlight.ts create mode 100644 src/executeHandler.ts create mode 100644 test-data/inline-parser.test.ts create mode 100644 test-data/semantic-tokens.test.ts create mode 100644 test-data/signature.test.ts create mode 100644 test-data/tree-sitter.test.ts diff --git a/README.md b/README.md index ac4638eb..c6563918 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ Introducing the [fish-lsp](https://fish-lsp.dev), a [Language Server Protocol (L | --- | --- | --- | | __Completion__ | Provides completions for commands, variables, and functions | ✅ | | __Hover__ | Shows documentation for commands, variables, and functions. Has special handlers for `--flag`, `commands`, `functions`, `variables` | ✅ | -| __Signature Help__ | Shows the signature of a command or function | ✖ | +| __Signature Help__ | Shows the signature of a command or function | ✅ | | __Goto Definition__ | Jumps to the definition of a command, variable, or function | ✅ | | __Find References__ | Shows all references to a command, variable, or function | ✅ | | __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_ | ✅ | -| __Document Highlight__ / __Semantic Token__ | Highlights all references to a command, variable, or function. | ✖ | +| __Document Highlight__ / __Semantic Token__ | Highlights all references to a command, variable, or function. | ✅ | | __Command Execution__ | Executes a server command from the client | ✖ | | __Code Action__ | Shows all available code actions | ✖ | | __Code Lens__ | Shows all available code lenses | ✖ | diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index d11ede0a..0a64ad73 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,57 +1,121 @@ -# Contributing + +# Contributing :busts_in_silhouette: The [fish-lsp](https://fish-lsp.dev) aims to create an experience that aligns with the [fish](https://fishshell.com) language goals. In a quick overview, the project hopes to create a development environment that is as _friendly_ as possible. -_Thanks for the interest in contributing to the project_ 🙏 - +_Thanks for the interest in contributing to the project_ :pray: --- -There are many ways to contribute to the project: +__There are many ways to contribute to the project:__ - Submit bugs, and help work on fixes -- __Refactor__ out unnecessary source code. +- __Refactor__ out unnecessary source code - Implement features, outlined in the [roadmap](./ROADMAP.md). - Implement new client configurations, outlined in this [repo](https://github.com/ndonfris/fish-lsp-language-clients/blob/master/) - Add [tests](https://github.com/ndonfris/fish-lsp/blob/master/test-data) to verify expected behavior. -- Update documentation, across any of the project's repositories +- Update __documentation__, across any of the project's repositories -## Getting started +## Getting started :rocket: 1. Begin by forking the project, then [build your local fork](../README.md#installation) :card_file_box:. 2. Once you have installed the local fork of the project (_i.e.,_ you have a successfully compiled `fish-lsp` executable, and have a working [client configuration](https://github.com/ndonfris/fish-lsp-language-clients)), -you can then begin testing locally :memo:. +you can then begin [testing locally](#helpful-workflows) :memo:. 3. Upon completing a change, submit a [PR](https://github.com/ndonfris/fish-lsp/pulls) :tada:. -## Places to start +## Places to Start :checkered_flag: -- [ROADMAP](./ROADMAP.md) - _future ideas to support_ +- [Roadmap](./ROADMAP.md) - _future ideas to support_ - [Issues and discussions](https://github.com/ndonfris/fish-lsp/discussions) - _get ideas from others_ - [Sources](https://github.com/ndonfris/fish-lsp/wiki/sources) - _helpful insight about potential features you want to adapt_ -## Workflows +> [!NOTE] +> Browsing both [wiki/sources#vscode-extensions-examples](https://github.com/ndonfris/fish-lsp/wiki/sources#vscode-extension-examples) and [ROADMAP](./ROADMAP.md) are the easiest method for +> understanding how to create future [fish-lsp feature's](https://github.com/ndonfris/fish-lsp#features) + +## Helpful Workflows :hourglass: + +### Test Driven Development Workflow :hatching_chick: + +Since __stdin/stdout__ are reserved for the protocol to communicate, a generally +successful method to achieve quick results, is through [TDD (Test Driven +Development)](https://en.wikipedia.org/wiki/Test-driven_development). Many tree-sitter helper functions ([tree-sitter.ts](../src/utils/tree-sitter.ts), and [node-types.ts](../src/utils/node-types.ts)) have already been written, to +aid in providing useful functionality for generic support of any possible +combination need for future types. -### TDD Workflow +Having said that, if you a need for a new definition in [tree-sitter.ts](../src/utils/tree-sitter.ts) or [node-types.ts](../src/utils/node-types.ts) comes up, +adding it to the proper file is fine (`tree-sitter.ts` genrally deals with movement or interacting with +a `SyntaxNode[] | Tree`, where as `node-types.ts` generally deals with filter +functions that can determine what __type__ of `SyntaxNode` is passed into it). +The only requirement is that you will for new additions to these files, is that +you include proper tests in their corresponding [test-data/{node-types,tree-sitter}.test.ts](https://github.com/ndonfris/fish-lsp/blob/master/test-data/)) -Since stdin/stdout are reserved for the protocol to communicate, a generally -successful method to achieve quick results, is through TDD (Test Driven -Development). Many tree-sitter helper functions have already been written, to -aid in providing useful functionality further down the release cycle. +
+ Sceenshot -### Integration Testing Workflow +![](https://github.com/ndonfris/fish-lsp/blob/d797189991cb55259d28aa43ff15b547fb454835/unit-testing.png?raw=true) -Test directly in the client of your choosing. This is more difficult to setup, +
+ +### Integration Testing Workflow :exploding_head: + +Test directly in the [client](https://github.com/ndonfris/fish-lsp-language-clients) of your choosing. _This is a more difficult to setup_, but could be helpful if you are testing specific behaviors like the interacting -with fish-lsp's environment variables, configuration options, handler testing or +with [fish-lsp's environment variables](https://github.com/ndonfris/fish-lsp/?tab=readme-ov-file#environment-variables), [configuration options](https://github.com/ndonfris/fish-lsp/?tab=readme-ov-file#command-flags), handler testing or other more specific tasks. +
+ Screenshot + +![](https://github.com/ndonfris/fish-lsp/blob/d797189991cb55259d28aa43ff15b547fb454835/integration-testing.png?raw=true) + +
+ +### How to Build using these Workflows :building_construction: + +1. __Pull up__ some Documentaion :microscope: + - [lsif](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#languageFeatures) - The official Language Server Protocol specification + - [wiki/sources](https://github.com/ndonfris/fish-lsp/wiki/sources) - Sources that are similar to this project + - [roadmap](./ROADMAP.md) - Ideas/Documentation for future plans + +1. __Create__ a `file` in the [test-data/](https://github.com/ndonfris/fish-lsp/tree/master/test-data) directory :construction_worker: + - __START WITH VERY BASIC EXAMPLES!!!__ [Pure functions](https://en.wikipedia.org/wiki/Pure_function) are your friend + - Checkout [./test-data/helpers.ts](../test-data/helpers.ts), `setLogger()` which is provided for `logging` tests + - Test your `FILE.test.ts` with command: `yarn test FILE --watchAll` + - Feel free to overwrite _any existing test-file_ that is not in the + [package.json](../package.json) key `test-hook` + - Use `import { initalizeParser } from '../src/parser` for building + `SyntaxNode[]` [composite object](https://en.wikipedia.org/wiki/Composite_pattern) arrays (aka [trees](https://en.wikipedia.org/wiki/Tree_traversal)). + +1. __Iteratively__ continue improving your feature :infinity: + - Once you have a feature's hard coded input & outputs working as + expected, you can begin trying to impalement it as an actual `server.handler` + - You can try adding logging to your feature's specific `handlerParams`, to get an exact + example of it's shape. (_This is the premise outlined via:_ [integration testing workflow](#integration-testing-workflow-explodinghead)) + + > ```fish + > # display the logs + > tail -f $(fish-lsp info --logs-file) + > ``` + + - Alternatively, you can mock the data-type from the `vscode-languageserver` + or refer to the [same documentation on lsif](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#definitionParams) + +1. __Add__ your feature to a [server.ts](../src/server.ts) `handler` :handshake: + - Document your handler, if necessary. + - Feel free to submit your [server handler](../src/server.ts) in __seperate__ working release stages, + instead of trying to build entire feature's independently. (_i.e._, if your + `CodeAction's` only support a singular `CodeActionType`) + - Submit your [PR](https://github.com/ndonfris/fish-lsp/pulls) :champagne: + + -## Helpful Topics and Concepts +## Helpful Topics and Concepts :books: [Currying](https://en.wikipedia.org/wiki/Currying) is a useful design pattern, that makes iterating through the -[Abstract Syntax Trees (ASTs)](https://en.wikipedia.org/wiki/Abstract_syntax_tree) significantly less error prone. Earlier language server -protocol versions required the Nodes in our tree items, to be stored as a flat list. -Due to this reason, there has been the need for a significant rewrite of previously -working features (diagnostics, etc...). __This would be a great place to start__, as many of -[server](../src/server.ts) providers are implemented using range based location -calculation's to abide to their prior protocol use. +[Abstract Syntax Trees (ASTs)](https://en.wikipedia.org/wiki/Abstract_syntax_tree) significantly less error prone. + +> [!NOTE] +> While it is still not entirely perfect, errors that appear to be caused by inconsistencies +> in our [node-types.ts](../src/utils/node-types.ts) [functors](https://en.wikipedia.org/wiki/Functor_(functional_programming)) are more likely to be +> caused by the earlier language server protocol versions requirement for our Nodes in our tree items, to be stored as a flat list. +> +> Due to this reason, the project has undergone a significant rewrite of previously +> working features (diagnostics, etc...). __Working on reintroducing the disabled features would be a great place to start__, +> as many of [server](../src/server.ts) providers were implemented using range based location +> calculation's to abide to their prior protocol use. +> +> Relevant examples for each of the feature's mentioned above are included @ [wiki/sources](https://github.com/ndonfris/fish-lsp/wiki/sources#vscode-extension-examples) [Child process](https://nodejs.org/api/child_process.html) execution via sub-shells. Sub-shell environment's are extensively relied on throughout the code base. @@ -96,19 +167,62 @@ are extensively relied on throughout the code base. Prefetching relevant information and [caching](https://en.wikipedia.org/wiki/Cache_(computing)) it for global use. -## Important Tooling Provided +## Important Tooling Provided :toolbox: + +- [tree-sitter](https://www.google.com/search?client=firefox-b-1-d&q=web-tree-sitter) - used for data structures/algorithms, prevalent to the shell language. + - `yarn sh:build-wasm ` - should handle building the wasm file + - `@esdmr/tree-sitter-fish@3.5.1` - handles installing the actual tree-sitter-fish.wasm package + - `web-tree-sitter` - is the API for `SyntaxNode[]`, `Parser`, `Range`, etc... -- [tree-sitter](https://www.google.com/search?client=firefox-b-1-d&q=web-tree-sitter) - used for data structures/algorithms, prevelant to the shell language. - [eslint](https://eslint.org/) - used for linting and formatting + - `yarn lint` - lint and fix the current project (`husky pre-push` hook) + - `yarn lint:verbose` - lint, and display output + - [knip](https://github.com/webpro/knip) - used for tree-shaking and checking unused dependcies + - `yarn refactacor` - package.json script to run knip + - You can _refactor_ major sections of __unused code__ of out the project easily + with this command + - [commander.js](https://github.com/tj/commander.js) - used for [src/cli.ts](../src/cli.ts) and other tooling to start the server + - Handles parsing the [./bin/fish-lsp](../bin/fish-lsp) `stdin`, in a structured manor + +- [zod](https://github.com/colinhacks/zod) - parses the `env` into a [typesafe object](https://github.com/ndonfris/fish-lsp/blob/a41b2654cc7607993b3fd80c8560e2fdcfeca6d2/src/config.ts#L86C54-L86C55) + - handles _parsing_ the `fish_lsp*` variables in our __node__ `process.env` object + - _builds_ the result object in the global variable `config` + +- [vscode-languageserver](https://github.com/Microsoft/vscode-languageserver-node) - the _SPEC_ for defining our _LSP_. + - `Objects` & `Interfaces` specific to `fish-lsp` typically __extend__ this base + specification + - `Type Definitions` useful for handler's are defined throughout this + package + +- [husky](https://typicode.github.io/husky/) - the [git-hooks](https://github.com/ndonfris/fish-lsp/blob/a41b2654cc7607993b3fd80c8560e2fdcfeca6d2/package.json#L42) for interacting with project's source code + - lints the project `on-push` + - removes dependencies before commit `pre-commit` + - initializes yarn `post-merge` + +- [jest](https://jestjs.io/docs/configuration) - testing the project + - relevant locations: [test-data/*.test.ts](https://github.com/ndonfris/fish-lsp/blob/master/test-data), [jest.config.js](https://github.com/ndonfris/fish-lsp/blob/master/jest.config.js) && [tsconfig.test.json](https://github.com/ndonfris/fish-lsp/blob/master/tsconfig.test.js) + - `yarn test-hook` is a future supported feature, for specifying individual test files to opt into + successful reporting for master branch (_via_ `git action`). + - `yarn test someFile.test.ts --watchAll` is the designated method for watching a test's changes -Becoming familiar with using the tree-sitter code, is significantly easier while -using the previously mentioned __TDD__. Although, another helpful method is -avaliable for any neovim devlepers via the `:InspectEdit` command. This will -allow you to visualize the AST that tree-sitter parsed from fish input. +### Other Noteworthy Tooling :hammer_and_wrench: -## Adding support to new Langauge Clients/Editors +Becoming familiar with using the `src/utils/{tree-sitter,node-types}.ts` code, is significantly easier while +using the previously mentioned [TDD Workflow](#test-driven-development-workflow-hatchingchick). + +Using an equivalent tree-sitter visualization command to neovim's command, `:InspectEdit` is also +highly recommended. If you are unsure what this command does, it essentially allows you to visualize +the AST that tree-sitter parsed from fish input. Using this while writing test +files, significantly improves the overall testing experience. + +Also don't forget to make use of the [fish-lsp --flags](https://github.com/ndonfris/fish-lsp/blob/master/README.md#command-flags)! There is plenty of use cases the binary supports, +like:
`fish-lsp env --create`, `fish-lsp complete`, +`fish-lsp info --logs-file`,
`fish-lsp info --time`, `fish-lsp url +--sources`, `fish-lsp logger --show`, ___+ more...___ + +## Adding New Language Clients :chart_with_upwards_trend: Generally, all that is required is using the `fish-lsp start` command, and specifying fish for attaching the server to a filetype. Any other fluff in this @@ -116,15 +230,18 @@ settings, as seen in the [JSON](../README.md#client-usage) example, is only for Adding new client configurations, to the [fish-lsp-client's](https://github.com/ndonfris/fish-lsp-language-clients/) repo, is greatly appreciated! -## Add helpful scripts +## Contributing Github Actions :recycle: -[Show & tell](https://github.com/ndonfris/fish-lsp/discussions) is a helpful place to document your useful configurations for working on the fish-lsp. +If you're trying to add a new github action to the project, please take a close +look at the [scripts/*](https://github.com/ndonfris/fish-lsp/tree/master/scripts) directory, along with [package.json](https://github.com/ndonfris/fish-lsp/blob/master/package.json). -Displaying demos, features and other cool discoveries is also welcomed. +A github __action__ that uses that compiles the project, requires `fish` to be +installed and setup, before `yarn` in the __action__. - +[Show & tell](https://github.com/ndonfris/fish-lsp/discussions) is a helpful place to document your useful configurations for working on the fish-lsp. + +Displaying demos, features and other cool discoveries are also welcome :) diff --git a/package.json b/package.json index 99de8241..a0ba2ea3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "clean:all": "yarn exec rimraf out *.tsbuildinfo node_modules tree-sitter-fish.wasm", "fresh": "yarn run clean:all && yarn install && yarn run setup", "pretest-hook": "yarn clean:all && yarn install --ignore-scripts && yarn run sh:setup", - "test-hook": "yarn jest document.test.ts node-types.test.ts fish-syntax-node.test.ts exec.test.ts logger.test.ts parser.test.ts completion-startup-config.test.ts document.test.ts formatting.test.ts", + "test-hook": "yarn jest document.test.ts tree-sitter.test.ts node-types.test.ts fish-syntax-node.test.ts exec.test.ts logger.test.ts parser.test.ts snippets.test.ts completion-startup-config.test.ts formatting.test.ts", "test": "jest", "compile": "yarn tsc -b", "watch": "yarn tsc -b -w", @@ -46,6 +46,14 @@ "post-merge": "yarn" } }, + "contributes": { + "commands": [ + { + "command": "fish-lsp.executeLine", + "title": "execute the line under the cursor" + } + ] + }, "dependencies": { "@esdmr/tree-sitter-fish": "^3.5.1", "colors": "^1.4.0", @@ -91,4 +99,4 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2" } -} +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index de3c8f45..d5e07aa7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -255,7 +255,7 @@ export function adjustInitializeResultCapabilitiesFromConfig(configHandlers: z.i resolveProvider: true, } : undefined, executeCommandProvider: configHandlers.executeCommand ? { - commands: ['APPLY_REFACTORING', 'SELECT_REFACTORING', 'APPLY_WORKSPACE_EDIT', 'RENAME', 'onHover', 'rename'], + commands: ['APPLY_REFACTORING', 'SELECT_REFACTORING', 'APPLY_WORKSPACE_EDIT', 'RENAME', 'onHover', 'rename', 'fish-lsp.executeLine'], workDoneProgress: true, } : undefined, documentSymbolProvider: { @@ -264,13 +264,10 @@ export function adjustInitializeResultCapabilitiesFromConfig(configHandlers: z.i workspaceSymbolProvider: { resolveProvider: true, }, - documentHighlightProvider: false, + documentHighlightProvider: configHandlers.highlight, inlayHintProvider: false, /*configHandlers.inlayHint,*/ - signatureHelpProvider: configHandlers.signature ? { - retriggerCharacters: ['.'], - triggerCharacters: ['.', ' '], - } : undefined, + signatureHelpProvider: configHandlers.signature ? {workDoneProgress: false, triggerCharacters: ['.']} : undefined, }, }; -} +} \ No newline at end of file diff --git a/src/document-highlight.ts b/src/document-highlight.ts new file mode 100644 index 00000000..97ea0b28 --- /dev/null +++ b/src/document-highlight.ts @@ -0,0 +1,45 @@ + +import { toSymbolKind } from './utils/translation'; +import { equalRanges, getNodeAtPosition, getRange } from './utils/tree-sitter'; +import { DocumentHighlight, DocumentHighlightKind } from 'vscode-languageserver'; +import Parser, { SyntaxNode, Tree } from 'web-tree-sitter'; + +/** + * TODO: + * ADD DocumentHighlightKind.Read | DocumentHighlightKind.Write support + */ +export function getDocumentHighlights(tree: Tree, node: SyntaxNode): DocumentHighlight[] { + const highlights: DocumentHighlight[] = []; + + const nodeSymbolKind = toSymbolKind(node) + + + function visitNode(currentNode: SyntaxNode) { + if (!currentNode) return; + + const currSymbolKind = toSymbolKind(currentNode) + const equalKinds = (currSymbolKind === nodeSymbolKind || currentNode.type === node.type) + if (equalKinds && currentNode.text === node.text) { + highlights.push({ + range: { + start: { + line: currentNode.startPosition.row, + character: currentNode.startPosition.column, + }, + end: { + line: currentNode.endPosition.row, + character: currentNode.endPosition.column, + }, + }, + // kind: DocumentHighlightKind.Text, + kind: equalRanges(getRange(currentNode), getRange(node)) + ? DocumentHighlightKind.Read + : DocumentHighlightKind.Text + }); + } + currentNode.children.forEach(child => visitNode(child)); + } + + visitNode(tree.rootNode); + return highlights; +} diff --git a/src/executeHandler.ts b/src/executeHandler.ts new file mode 100644 index 00000000..1adf477b --- /dev/null +++ b/src/executeHandler.ts @@ -0,0 +1,19 @@ +import { TextDocumentPositionParams, Connection } from 'vscode-languageserver'; +import {exec} from 'child_process' + + export function execRequest(connection: Connection, line: string) { + // Here you would execute the current line in the parent shell environment + // For example, you could use Node.js's child_process to execute the command + exec(line, (error: any, stdout: any, stderr: any) => { + if (error) { + connection.window.showErrorMessage(`Error: ${error.message}`); + return; + } + if (stderr) { + connection.window.showErrorMessage(`Stderr: ${stderr}`); + return; + } + connection.window.showInformationMessage(`Executed: ${line}\nOutput: ${stdout}`); + }); +} + diff --git a/src/hover.ts b/src/hover.ts index 73d9ec40..115bb680 100644 --- a/src/hover.ts +++ b/src/hover.ts @@ -6,8 +6,10 @@ import { LspDocument } from './document'; import { documentationHoverProvider, enrichCommandWithFlags } from './documentation'; import { DocumentationCache } from './utils/documentationCache'; import { execCommandDocs, execCompletions, execSubCommandCompletions } from './utils/exec'; -import { isCommand } from './utils/node-types'; +import { isCommand, isFunctionDefinition, isOption } from './utils/node-types'; import { findFirstParent } from './utils/tree-sitter'; +import { symbolKindToString, symbolKindsFromNode, toSymbolKind } from './utils/translation'; +import { Logger } from './logger'; export async function handleHover( analyzer: Analyzer, @@ -15,8 +17,9 @@ export async function handleHover( position: LSP.Position, current: Parser.SyntaxNode, cache: DocumentationCache, + logger?: Logger, ): Promise { - if (current.text.startsWith('-')) { + if (isOption(current)) { return await getHoverForFlag(current); } const local = analyzer.getDefinition(document, position); @@ -29,10 +32,16 @@ export async function handleHover( range: local.selectionRange, }; } + let { kindType, kindString } = symbolKindsFromNode(current) + const symbolType = ['function', 'class', 'variable'].includes(kindString) ? kindType : undefined + logger?.log({'./src/hover.ts:37': kindType}) + if (cache.find(current.text) !== undefined) { - await cache.resolve(current.text); - const item = cache.getItem(current.text); - if (item?.docs) { + await cache.resolve(current.text, document.uri, symbolType); + const item = !!symbolType ? cache.find(current.text, symbolType) : cache.getItem(current.text); + logger?.logAsJson('call: [./src/hover.ts:42]') + + if (item && item?.docs) { return { contents: { kind: MarkupKind.Markdown, @@ -42,11 +51,14 @@ export async function handleHover( } } const commandString = await collectCommandString(current); - return await documentationHoverProvider(commandString); + + const result = await documentationHoverProvider(commandString); + logger?.log({commandString, result}) + return result } export async function getHoverForFlag(current: Parser.SyntaxNode): Promise { - const commandNode = findFirstParent(current, n => isCommand(n)); + const commandNode = findFirstParent(current, n => isCommand(n) || isFunctionDefinition(n)); if (!commandNode) { return null; } @@ -127,4 +139,4 @@ export async function collectCommandString(current: Parser.SyntaxNode): Promise< return commandText; } return commandNodeText || ''; -} +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 8290daa5..c5d11619 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,13 +2,14 @@ import Parser, { SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from './parser'; import { Analyzer } from './analyze'; //import { generateCompletionList, } from "./completion"; -import { InitializeParams, TextDocumentSyncKind, CompletionParams, Connection, CompletionList, CompletionItem, MarkupContent, DocumentSymbolParams, DefinitionParams, Location, ReferenceParams, DocumentSymbol, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidSaveTextDocumentParams, InitializeResult, HoverParams, Hover, RenameParams, TextDocumentPositionParams, TextDocumentIdentifier, WorkspaceEdit, TextEdit, DocumentFormattingParams, CodeActionParams, CodeAction, DocumentRangeFormattingParams, FoldingRangeParams, FoldingRange, InlayHintParams, MarkupKind, WorkspaceSymbolParams, WorkspaceSymbol, SymbolKind, CompletionTriggerKind, SignatureHelpParams, SignatureHelp, MessageType, NotificationType } from 'vscode-languageserver'; +import { InitializeParams, TextDocumentSyncKind, CompletionParams, Connection, CompletionList, CompletionItem, MarkupContent, DocumentSymbolParams, DefinitionParams, Location, ReferenceParams, DocumentSymbol, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidSaveTextDocumentParams, InitializeResult, HoverParams, Hover, RenameParams, TextDocumentPositionParams, TextDocumentIdentifier, WorkspaceEdit, TextEdit, DocumentFormattingParams, CodeActionParams, CodeAction, DocumentRangeFormattingParams, FoldingRangeParams, FoldingRange, InlayHintParams, MarkupKind, WorkspaceSymbolParams, WorkspaceSymbol, SymbolKind, CompletionTriggerKind, SignatureHelpParams, SignatureHelp, MessageType, NotificationType, DocumentHighlight, DocumentHighlightParams, ExecuteCommandParams } from 'vscode-languageserver'; +import {execRequest} from './executeHandler' import * as LSP from 'vscode-languageserver'; import { LspDocument, LspDocuments } from './document'; import { formatDocumentContent } from './formatting'; import { Logger, ServerLogsPath } from './logger'; -import { uriToPath } from './utils/translation'; -import { findFirstParent, getChildNodes, getRange } from './utils/tree-sitter'; +import { pathToUri, symbolKindToString, symbolKindsFromNode, toSymbolKind, uriToPath } from './utils/translation'; +import { findFirstParent, getChildNodes, getNodeAtPosition, getRange } from './utils/tree-sitter'; import { handleHover } from './hover'; import { /*getDiagnostics*/ } from './diagnostics/validate'; import { CodeActionKind } from './code-action'; @@ -30,9 +31,13 @@ import { getDocumentationResolver } from './utils/completion/documentation'; import { FishCompletionList } from './utils/completion/list'; import { config } from './cli'; import { PrebuiltDocumentationMap, getPrebuiltDocUrl, getPrebuiltDocUrlByName } from './utils/snippets'; -import { isCommand, isVariableDefinition, isVariableDefinitionCommand } from './utils/node-types'; +import { findParentCommand, isCommand, isCommandName, isVariableDefinition, isVariableDefinitionCommand } from './utils/node-types'; import { adjustInitializeResultCapabilitiesFromConfig, configHandlers } from './config'; import { enrichToMarkdown } from './documentation'; +import { getAliasedCompletionItemSignature, lineSignatureBuilder } from './signature'; +import { CompletionItemMap } from './utils/completion/startup-cache'; +import { getDocumentHighlights } from './document-highlight'; +import { pathToFileURL } from 'url'; // @TODO export type SupportedFeatures = { @@ -46,11 +51,13 @@ export default class FishServer { ): Promise { const documents = new LspDocuments(); const logger = new Logger(config.fish_lsp_logfile || ServerLogsPath, true, connection.console); + const completionsMap = await CompletionItemMap.initialize() + return await Promise.all([ initializeParser(), initializeDocumentationCache(), initializeDefaultFishWorkspaces(), - initializeCompletionPager(logger), + initializeCompletionPager(logger, completionsMap), ]).then(([parser, cache, workspaces, completions]) => { const analyzer = new Analyzer(parser, workspaces); return new FishServer( @@ -59,6 +66,7 @@ export default class FishServer { analyzer, documents, completions, + completionsMap, cache, logger, ); @@ -75,6 +83,7 @@ export default class FishServer { private analyzer: Analyzer, private docs: LspDocuments, private completion: CompletionPager, + private completionMap: CompletionItemMap, private documentationCache: DocumentationCache, protected logger: Logger, ) { @@ -111,8 +120,10 @@ export default class FishServer { connection.onCodeAction(this.onCodeAction.bind(this)); connection.onFoldingRanges(this.onFoldingRanges.bind(this)); //this.connection.workspace.applyEdit() + connection.onDocumentHighlight(this.onDocumentHighlight.bind(this)); connection.languages.inlayHint.on(this.onInlayHints.bind(this)); connection.onSignatureHelp(this.onShowSignatureHelp.bind(this)); + connection.onExecuteCommand(this.onExecuteCommand.bind(this)); connection.console.log('FINISHED FishLsp.register()'); } @@ -271,6 +282,48 @@ export default class FishServer { ); } + public onExecuteCommand(params: ExecuteCommandParams) { + this.logParams('onExecuteCommand', params); + + const file = params.arguments![0] as string || '' + const line = params.arguments![1] as string || '' + // console.log({'last accessed: ': this.docs.files}) + + if (!file || !line) { + this.logger.log({gotNull: 'gotNull', file, line}) + return null + } + + const doc = this.docs.get(file) + + if (!doc) { + this.logger.log({title: 'docs was null', doc}) + return [] + } + const text = doc.getLine(Number.parseInt(line)-1) + this.logParams('onExecuteCommand', text) + execRequest(this.connection, text) + return null + } + + /** + * highlight provider + */ + onDocumentHighlight(params: DocumentHighlightParams): DocumentHighlight[] { + this.logParams('onDocumentHighlight', params) + const { doc } = this.getDefaults(params) + if (!doc) return [] + + const text = doc.getText(); + const tree = this.parser.parse(text); + const node = getNodeAtPosition(tree, params.position); + if (!node) return [] + + const highlights = getDocumentHighlights(tree, node); + + return highlights + } + async onWorkspaceSymbol(params: WorkspaceSymbolParams): Promise { this.logParams('onWorkspaceSymbol', params.query); @@ -296,9 +349,7 @@ export default class FishServer { return getRefrenceLocations(this.analyzer, doc, params.position); } - // opens package.json on hover of document symbol! - // - // NEED TO REMOVE documentationCache. It works but is too expensive memory wise. + // 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 { @@ -307,7 +358,10 @@ export default class FishServer { if (!doc || !uri || !root || !current) { return null; } - // this.logger.log({ current: current.text }); + + + let { kindType, kindString } = symbolKindsFromNode(current) + this.logger.log({ currentText: current.text, currentType: current.type, symbolKind: kindString }); const prebuiltSkipType = [ ...PrebuiltDocumentationMap.getByType('pipe'), @@ -328,12 +382,21 @@ export default class FishServer { ].join('\n')), }; } + const symbolType = [ + 'function', + 'class', + 'variable', + ].includes(kindString) ? kindType : undefined + const globalItem = await this.documentationCache.resolve( current.text.trim(), uri, + symbolType ); - this.logger.logAsJson('docCache found ' + globalItem?.resolved.toString() || `docCache not found ${current.text}`); + + this.logger.log({'./src/server.ts:395': `this.documentationCache.resolve() found ${!!globalItem}` , docs: globalItem.docs}); if (globalItem && globalItem.docs) { + this.logger.log(globalItem.docs) return { contents: { kind: MarkupKind.Markdown, @@ -341,13 +404,16 @@ export default class FishServer { }, }; } - return await handleHover( + const fallbackHover = await handleHover( this.analyzer, doc, params.position, current, this.documentationCache, + // this.logger, ); + this.logger.log(fallbackHover?.contents); + return fallbackHover } // workspace.fileOperations.didRename @@ -546,29 +612,41 @@ export default class FishServer { const { doc, uri } = this.getDefaults(params); if (!doc || !uri) return null; + const { line, lineRootNode, lineLastNode } = this.analyzer.parseCurrentLine(doc, params.position); - const varNode = getChildNodes(lineRootNode).find(c => isVariableDefinition(c)); - const lastCmd = getChildNodes(lineRootNode).filter(c => isCommand(c)).pop(); - this.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, - }; - } - return null; + if (line.trim() === '') return null + const currentCmd = findParentCommand(lineLastNode)! + // const commands = getChildNodes(lineRootNode).filter(isCommand) + const aliasSignature = this.completionMap.allOfKinds('alias').find(a => a.label === currentCmd.text) + if (aliasSignature) return getAliasedCompletionItemSignature(aliasSignature) + return lineSignatureBuilder(currentCmd, lineLastNode, this.completionMap) + // if (currentCmd.text.startsWith('string') || commands.length > 1) { + // } + // + // return lineSignatureBuilder(lineRootNode, lineLastNode) + // + // const varNode = getChildNodes(lineRootNode).find(c => isVariableDefinition(c)); + // const lastCmd = getChildNodes(lineRootNode).filter(c => isCommand(c)).pop(); + // this.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, + // }; + // } + // return null; } ///////////////////////////////////////////////////////////////////////////////////// diff --git a/src/signature.ts b/src/signature.ts index 7fc10f6e..1ebc03f5 100644 --- a/src/signature.ts +++ b/src/signature.ts @@ -5,11 +5,143 @@ import { SignatureHelpParams, Command, } from 'vscode-languageserver'; -//import { FishCompletionItemKind } from './utils/completion-strategy'; -//import { stringRegexExpressions} from './utils/completion-types'; -import { isBuiltin } from './utils/builtins'; +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'; + +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); -function regexStringSignature() : SignatureInformation { + 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, { shortOption: '-r', longOption: '--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 regexItems = stringRegexExpressions; //let signatureDoc = ["__String Regex Patterns__", "---"]; //for (const item of regexItems) { @@ -108,4 +240,4 @@ const markdownStringGroups = [ '- __(?:...)__ 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'); +].join('\n'); \ No newline at end of file diff --git a/src/utils/completion/pager.ts b/src/utils/completion/pager.ts index 88370b54..edf171c4 100644 --- a/src/utils/completion/pager.ts +++ b/src/utils/completion/pager.ts @@ -140,13 +140,9 @@ export class CompletionPager { } } -export async function initializeCompletionPager(logger: Logger) { - return await Promise.all([ - InlineParser.create(), - CompletionItemMap.initialize(), - ]).then(([inline, items]) => { - return new CompletionPager(inline, items, logger); - }); +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) { @@ -375,4 +371,4 @@ function addItemTypes(line: string, parser: InlineParser): FishCompletionItemKin break; } return []; -} +} \ No newline at end of file diff --git a/src/utils/completion/startup-cache.ts b/src/utils/completion/startup-cache.ts index c93c284a..1abcc59f 100644 --- a/src/utils/completion/startup-cache.ts +++ b/src/utils/completion/startup-cache.ts @@ -7,9 +7,8 @@ import { SetupItemsFromCommandConfig } from './startup-config'; export type ItemMapRecord = Record; export class CompletionItemMap { - constructor( - private _items: ItemMapRecord = {} as ItemMapRecord, - ) {} + + constructor(private _items: ItemMapRecord = {} as ItemMapRecord) {} static async initialize(): Promise { const result: ItemMapRecord = {} as ItemMapRecord; @@ -19,6 +18,7 @@ export class CompletionItemMap { const stdout = await execCmd(item.command); cmdOutputs.set(item.fishKind, stdout); })); + SetupItemsFromCommandConfig.forEach((item) => { const items: FishCompletionItem[] = []; const stdout = cmdOutputs.get(item.fishKind)!; @@ -36,12 +36,13 @@ export class CompletionItemMap { const detail = getCommandsDetail(value || item.detail); items.push(FishCompletionItem.create(label, item.fishKind, detail, line)); }); - result[item.fishKind] = items; + result[ item.fishKind ] = items; }); - Object.entries(StaticItems).forEach(([key, value]) => { + + Object.entries(StaticItems).forEach(([ key, value ]) => { const kind = key as FishCompletionItemKind; - if (!result[kind]) { - result[kind] = value.map((item) => FishCompletionItem.create( + if (!result[ kind ]) { + result[ kind ] = value.map((item) => FishCompletionItem.create( item.label, kind, item.detail, @@ -50,23 +51,30 @@ export class CompletionItemMap { )); } }); + return new CompletionItemMap(result); } + get(kind: FishCompletionItemKind): FishCompletionItem[] { - return this._items[kind] || []; + 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[]][]; + + 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)); + this.entries().forEach(([ key, value ]) => callbackfn(key, value)); } + allCompletionsWithoutCommand() { return this.allOfKinds( FishCompletionItemKind.ABBR, @@ -77,6 +85,7 @@ export class CompletionItemMap { //FishCompletionItemKind.VARIABLE, ); } + findLabel(label: string, ...searchKinds: FishCompletionItemKind[]): FishCompletionItem | undefined { const kinds: FishCompletionItemKind[] = searchKinds?.length > 0 ? searchKinds : this.allKinds; for (const kind of kinds) { diff --git a/src/utils/node-types.ts b/src/utils/node-types.ts index bad3b26e..0396b1de 100644 --- a/src/utils/node-types.ts +++ b/src/utils/node-types.ts @@ -221,10 +221,48 @@ export function isLongOption(node: SyntaxNode): boolean { export function isShortOption(node: SyntaxNode): boolean { return node.text.startsWith('-') && !isLongOption(node); } + export function isOption(node: SyntaxNode): boolean { return isShortOption(node) || isLongOption(node); } +/** 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 type NodeOptionQueryText = { + shortOption?: `-${string}` + oldUnixOption?: `-${string}`, + longOption?: `--${string}` +} + +/** + * @param node - the node to check + * @param optionQuery - object of node strings to match + * @returns boolean result corresponding to query + */ +export function isMatchingOption(node: SyntaxNode, optionQuery: NodeOptionQueryText) : boolean { + if (!isOption(node)) return false + + const nodeText = node.text.includes('=') ? node.text.slice(0, node.text.indexOf('=')) : node.text + + if (isLongOption(node) && optionQuery?.longOption === nodeText) return true + + if (isShortOption(node) && optionQuery?.oldUnixOption === nodeText) return true + + if (!optionQuery.shortOption) return false + return isShortOption(node) && hasShortOptionCharacter(node, optionQuery.shortOption.slice(1)) + +} + export function isPipe(node: SyntaxNode): boolean { return node.type === 'pipe'; } @@ -687,3 +725,29 @@ export function isPartialForLoop(node: SyntaxNode) { !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' +} + +// TODO: either move use or remove +// /** +// * checks for SyntaxNode.text === '-f1' | '--fields=1' +// * but not SyntaxNode.text !== '-1' | '-m1f1' | '--fields-1' +// */ +// export function isOptionWithValue(node: SyntaxNode) { +// if (!isOption(node)) return false +// // must be option +// +// if (isShortOption(node)) { +// const lastChar = node.text.charAt(2) || '' +// return Number.isInteger(Number.parseInt(lastChar)); +// } else if (isLongOption(node)) { +// return node.text.includes('=') +// } +// return false +// } +// diff --git a/src/utils/snippets.ts b/src/utils/snippets.ts index 8e9438a5..62fb9287 100644 --- a/src/utils/snippets.ts +++ b/src/utils/snippets.ts @@ -20,7 +20,7 @@ interface BaseJson { type JsonType = 'command' | 'pipe' | 'status' | 'variable'; type SpecialType = 'fishlsp' | 'env' | 'locale' | 'special' | 'theme'; -interface ExtendedBaseJson extends BaseJson { +export interface ExtendedBaseJson extends BaseJson { type: JsonType; specialType: SpecialType | undefined; // otherTypes: string[]; //TODO diff --git a/src/utils/translation.ts b/src/utils/translation.ts index 26bf35e3..e9c530e3 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -3,7 +3,7 @@ import * as LSP from 'vscode-languageserver'; import * as TreeSitter from 'web-tree-sitter'; import { SyntaxNode } from 'web-tree-sitter'; import { URI } from 'vscode-uri'; -import { findParentVariableDefintionKeyword, isCommand, isCommandName, isComment, isFunctionDefinition, isFunctionDefinitionName, isProgram, isScope, isStatement, isString, isVariableDefinition } from './node-types'; +import { findParentVariableDefintionKeyword, isCommand, isCommandName, isComment, isFunctionDefinition, isFunctionDefinitionName, isProgram, isScope, isStatement, isString, isVariable, isVariableDefinition } from './node-types'; import { LspDocument, LspDocuments } from '../document'; import { FishProtocol } from './fishProtocol'; import { getPrecedingComments, getRange, getRangeWithPrecedingComments } from './tree-sitter'; @@ -210,7 +210,7 @@ export function toLspDocument(filename: string, content: string): LspDocument { } export function toSymbolKind(node: SyntaxNode): SymbolKind { - if (isVariableDefinition(node)) { + if (isVariable(node)) { return SymbolKind.Variable; } else if (isFunctionDefinitionName(node)) { // change from isFunctionDefinition(node) return SymbolKind.Function; @@ -245,3 +245,22 @@ export function symbolKindToString(kind: SymbolKind) { return 'other'; } } + + +/** + * @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) { + const kindType = toSymbolKind(node); + const kindString = symbolKindToString(kindType) + return { + kindType, + kindString + } + +} \ No newline at end of file diff --git a/src/utils/tree-sitter.ts b/src/utils/tree-sitter.ts index fbf28b19..e129bd12 100644 --- a/src/utils/tree-sitter.ts +++ b/src/utils/tree-sitter.ts @@ -3,9 +3,9 @@ import { extname, join } from 'path'; //import { pathToFileURL, URL } from 'url' import { Position, Range, URI } from 'vscode-languageserver'; import { Point, SyntaxNode, Tree } from 'web-tree-sitter'; -import { pathToFileURL } from 'url'; // typescript-langauge-server -> https://github.com/typescript-language-server/typescript-language-server/blob/master/src/document.ts -import vscodeUri from 'vscode-uri'; // typescript-langauge-server -> https://github.com/typescript-language-server/typescript-language-server/blob/master/src/document.ts -import { existsSync } from 'fs-extra'; +// import { pathToFileURL } from 'url'; // typescript-langauge-server -> https://github.com/typescript-language-server/typescript-language-server/blob/master/src/document.ts +// import vscodeUri from 'vscode-uri'; // typescript-langauge-server -> https://github.com/typescript-language-server/typescript-language-server/blob/master/src/document.ts +// import { existsSync } from 'fs-extra'; import { findSetDefinedVariable, findParentCommand, isFunctionDefinition, isVariableDefinition, isFunctionDefinitionName, isVariable, isScope, isProgram, isCommandName, isForLoop, findForLoopVariable } from './node-types'; /** @@ -106,7 +106,7 @@ export function findFirstParent(node: SyntaxNode, predicate: (node: SyntaxNode) */ export function getSiblingNodes( node: SyntaxNode, - predicate : (n: SyntaxNode) => true, + predicate : (n: SyntaxNode) => boolean, direction: 'before' | 'after' = 'before', ): SyntaxNode[] { const siblingFunc = (n: SyntaxNode) => @@ -151,7 +151,7 @@ export function findFirstSibling( direction === 'before' ? n.previousSibling : n.nextSibling; let current: SyntaxNode | null = node; while (current) { - console.log('curr: ', current.text); + // console.log('curr: ', current.text); current = siblingFunc(current); if (current && predicate(current)) { return current; @@ -606,3 +606,6 @@ export function getCommandArgumentValue(command: SyntaxNode, argName: string): S // // return result //} +export function getNodeAtPosition(tree: Tree, position: { line: number; character: number }): SyntaxNode | null { + return tree.rootNode.descendantForPosition({ row: position.line, column: position.character }); +} diff --git a/test-data/fish_files/umask.fish b/test-data/fish_files/umask.fish index 779dc436..69ca0592 100644 --- a/test-data/fish_files/umask.fish +++ b/test-data/fish_files/umask.fish @@ -182,4 +182,4 @@ function umask --description "Set default file permission mask" printf (_ '%s: Too many arguments\n') umask >&2 return 1 end -end +end \ No newline at end of file diff --git a/test-data/formatting.test.ts b/test-data/formatting.test.ts index f6d82436..9ab89637 100644 --- a/test-data/formatting.test.ts +++ b/test-data/formatting.test.ts @@ -132,4 +132,3 @@ describe('formatting tests', () => { }) }) - diff --git a/test-data/inline-parser.test.ts b/test-data/inline-parser.test.ts new file mode 100644 index 00000000..9200cfaf --- /dev/null +++ b/test-data/inline-parser.test.ts @@ -0,0 +1,458 @@ + +import { setLogger } from './helpers'; +import { initializeParser } from '../src/parser'; +import Parser, { SyntaxNode, TreeCursor } from 'web-tree-sitter'; +import { FishSource, InlineParser } from '../src/utils/completion/inline-parser'; +import { getChildNodes, getLastLeaf, getLeafs } from '../src/utils/tree-sitter'; +import { ChildProcess, exec, spawn } from 'child_process'; +import { promisify } from 'util'; +import { Writable } from 'stream'; + +import { CompletionItem, CompletionItemKind, CompletionParams } from 'vscode-languageserver'; + +// jest.setTimeout(5000); // Increase the timeout if necessary +// import { Writable } from 'stream'; + +// import { promisify } from 'util'; +const execAsync = promisify(exec); + +function createJob(): ChildProcess { + const fishJob = spawn('fish', [ '-ic', 'while read val -P ""; complete -C "$val"; echo FISH_COMPLETION_END; end' ], { + stdio: [ 'pipe', 'pipe', 'ignore' ] + }); + + return fishJob; +} + +function handleStream( + stream: NodeJS.ReadableStream | null, + callback: (data: string) => void, + endCallback: () => void +): void { + if (stream) { + stream.on('data', (data) => { + const lines: string[] = data.toString().split('\n'); + lines.forEach((line: string) => { + if (line !== '') { + callback(line); + } + }); + }); + stream.on('end', endCallback); + } +} + +function handleStderr(fishJob: ChildProcess): void { + if (fishJob.stderr) { + fishJob.stderr.on('data', (data) => { + console.error(`stderr: ${data}`); + }); + } +} + +function handleClose(fishJob: ChildProcess): void { + // fishJob.on('close', (code) => { + // // console.log(`fish process exited with code ${code}`); + // }); +} + +function processOutput(outputBuffer: string[]): string[] { + return outputBuffer.map((item) => { + const index = item.indexOf('\t'); + if (index !== -1) { + const label = item.substring(0, index); + const detail = item.substring(index + 1); + return `${label} (${detail})`; + } + return item; + }); +} + + +export async function testCompletion(input: string): Promise { + const fishJob = createJob(); + const outputBuffer: string[] = []; + + return new Promise((resolve, reject) => { + handleStream(fishJob.stdout, (line) => { + if (line === 'FISH_COMPLETION_END') { + resolve(processOutput(outputBuffer)); + } else { + outputBuffer.push(line); + } + }, () => resolve(processOutput(outputBuffer))); + + handleStderr(fishJob); + handleClose(fishJob); + + if (!fishJob.stdin) { + reject(new Error('stdin is not available')); + } else { + const stdin: Writable = fishJob.stdin; + const writeAsync = promisify(stdin.write.bind(stdin)) as (chunk: any, encoding?: BufferEncoding) => Promise; + writeAsync(input + '\n') + .then(() => stdin.end()) + .catch(reject); + } + }); +} + +function processExecOutput(output: string): string[] { + return output.split('\n') + .filter(item => item.trim() !== '') + .map((item) => { + const index = item.indexOf('\t'); + if (index !== -1) { + const label = item.substring(0, index); + const detail = item.substring(index + 1); + return `${label} (${detail})`; + } + return item; + }); +} + +export async function testExecCompletion(input: string): Promise { + try { + const { stdout, stderr } = await execAsync(`echo '${input.replace(/'/g, "'\\''")}' | fish -ic 'while read val -P ""; complete -C "$val"; end'`); + if (stderr) { + console.error(`stderr: ${stderr}`); + } + return processExecOutput(stdout); + } catch (error) { + console.error('Error executing command:', error); + throw error; + } +} + +// +// +// Traversal +// +// +// function* customTraversal(cursor: TreeCursor): Generator { +// do { +// yield cursor.currentNode; +// if (cursor.gotoFirstChild()) { +// yield* customTraversal(cursor); +// cursor.gotoParent(); // go back to the parent after traversing children +// } +// } while (cursor.gotoNextSibling()); +// } +// +// async function traverseCustom(input: string) { +// const traverseParser = await initializeParser(); +// const tree = traverseParser.parse(input); +// const cursor = tree.rootNode.walk(); +// +// for (const node of customTraversal(cursor)) { +// console.log(node.type, node.text); +// } +// } +// +// // +// // +// // Custom Completion Targets +// // +// // +// // +// function extractCompletionTargets(node: Parser.SyntaxNode): string[] { +// const targets: string[] = []; +// +// function traverse(node: Parser.SyntaxNode) { +// if (node.type === 'command' || node.type === 'variable_name' || node.type === 'argument' || node.type === 'function') { +// targets.push(node.type+":"+node.text); +// } +// for (let i = 0; i < node.childCount; i++) { +// const current = node.child(i) +// if (current) traverse(current); +// } +// } +// +// traverse(node); +// return targets; +// } + +// async function parseForCompletion(input: string) { +// const cmpParser = await initializeParser(); +// const tree = cmpParser.parse(input); +// const rootNode = tree.rootNode; +// +// if (!rootNode) return; +// +// const targets = extractCompletionTargets(rootNode); +// +// console.log(JSON.stringify({"Line": input, 'Completion Targets': targets}, null, 2)); +// } + + +// async function parseCommandLine(input: string) { +// const cparser = await initializeParser(); +// const tree = cparser.parse(input); +// const rootNode = tree.rootNode; +// +// if (!rootNode) return; +// +// // Assume the cursor is at the end of the input +// const cursorPosition = input.length-1; +// +// // Find the node at the cursor position +// const currentNode = rootNode.descendantForIndex(cursorPosition); +// +// if (currentNode) { +// const functionNode = findPreviousProcess(currentNode); +// console.log('Previous Function Keyword Node:', functionNode?.text); +// } +// } + + +function findPreviousProcess(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let current: SyntaxNode | null = node; + while (current && !isProcess(current)) { + current = getPreviousNode(current); + } + console.log('currentPROC', current?.text || 'null'); + return current && isProcess(current) ? current : null; +} + +function getPreviousNode(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let current: SyntaxNode | null = node; + + while (current) { + if (isProcess(current)) { + current = current.previousSibling || current.parent; + } else { + break; + } + } + + // Traverse to the previous sibling or parent if no previous sibling exists + if (current && current.previousSibling) { + return current.previousSibling; + } else if (current && current.parent) { + return current.parent; + } + return null; +} + +function isProcess(node: Parser.SyntaxNode): boolean { + const processTypes = [ 'pipe', 'redirect_statement', 'subshell', '&&', '||', ';', 'function', 'command', 'for', 'while', 'if', 'switch', 'case' ]; + return processTypes.includes(node.type) || node.type.endsWith('statement'); +} + + +async function parseCommandLine(input: string) { + const parser = await initializeParser(); + const tree = parser.parse(input); + const rootNode = tree.rootNode; + + if (!rootNode) return; + + // Assume the cursor is at the end of the input + const cursorPosition = input.length; + + // Find the node at the cursor position + const currentNode = rootNode.descendantForIndex(cursorPosition); + + if (currentNode) { + const currentLine = getLineAtPosition(input, cursorPosition); + const combinedLines = getCombinedLines(input, cursorPosition); + + console.log('Combined Lines for Completion:', combinedLines); + // Use the combined lines for completions + parseForCompletion(combinedLines); + } +} + +function getLineAtPosition(input: string, position: number): string { + const lines = input.split('\n'); + let lineIndex = 0; + let charCount = 0; + + for (let i = 0; i < lines.length; i++) { + charCount += lines[ i ]!.length + 1; // +1 for newline character + if (charCount > position) { + lineIndex = i; + break; + } + } + + return lines[ lineIndex ] || ''; +} + +function getCombinedLines(input: string, position: number): string { + const lines = input.split('\n'); + let lineIndex = 0; + let charCount = 0; + let combinedLines = ''; + + for (let i = 0; i < lines.length; i++) { + charCount += lines[ i ]!.length + 1; // +1 for newline character + if (charCount > position) { + lineIndex = i; + break; + } + } + + // Combine lines upwards until no trailing backslash + for (let i = lineIndex - 1; i >= 0; i--) { + if (lines[ i ]!.endsWith('\\')) { + combinedLines = lines[ i ]!.slice(0, -1) + combinedLines; + } else { + break; + } + } + + combinedLines += lines[ lineIndex ]; // Add the current line + return combinedLines; + +} +// +// Function to get Fish shell completion +async function getFishCompletion(scriptContent: string): Promise { + // const command = `echo '${scriptContent.replace(/'/g, "'\\''")}' | fish -c 'source (string trim --right \\n); complete -C ""'`; + const command = `fish -c "string escape \'${scriptContent.replace(/'/g, "'\\''")}\' | read -t -a cmd; complete -C "$cmd"'`; + try { + const { stdout, stderr } = await execAsync(command); + if (stderr) { + return ''; + } + return stdout; + } catch (e) { + console.log(e); + } + return ''; +} + +// Function to parse the completion result from Fish shell into LSP CompletionItems +function parseCompletionItems(completionResult: string): CompletionItem[] { + return completionResult + .split('\n') + .filter((item) => item) + .map((item) => ({ + label: item, + kind: 1, // 1 is for Text in LSP CompletionItemKind + })); +} + +function parseForCompletion(line: string) { + // Here you can implement your logic to use the line for completions + console.log('Parsing line for completion:', line); +} + +setLogger(); +// Example usage: Simulating a command line for autocompletion +// const commandLine = 'echo "hello" && l'; +// parseForCompletion(commandLine); +describe('inline-parser test suite', () => { + + it('test inline-parser normal', async () => { + const input = 'function \\\\n --'; + const result = await testCompletion(input); + console.log(result); + expect(result).toContain('--argument-names (Specify named arguments)'); + expect(result).toContain('--description (Set function description)'); + expect(result).toContain('--inherit-variable (Snapshot and define local variable)'); + expect(result).toContain('--no-scope-shadowing (Do not shadow variable scope of calling function)'); + expect(result).toContain('--on-event (Make the function a generic event handler)'); + expect(result).toContain('--on-job-exit (Make the function a job exit event handler)'); + expect(result).toContain('--on-process-exit (Make the function a process exit event handler)'); + expect(result).toContain('--on-signal (Make the function a signal event handler)'); + expect(result).toContain('--on-variable (Make the function a variable update event handler)'); + expect(result).toContain('--wraps (Inherit completions from the given command)'); + // expect(true).toBeTruthy() + }); + +}); + +it('test exec inline-parser normal', async () => { + const input = 'function --'; + const result = await testExecCompletion(input); + console.log(result); + + // expect(result).toContain('--argument-names (Specify named arguments)'); + // expect(result).toContain('--description (Set function description)'); + // expect(result).toContain('--inherit-variable (Snapshot and define local variable)'); + // expect(result).toContain('--no-scope-shadowing (Do not shadow variable scope of calling function)'); + // expect(result).toContain('--on-event (Make the function a generic event handler)'); + // expect(result).toContain('--on-job-exit (Make the function a job exit event handler)'); + // expect(result).toContain('--on-process-exit (Make the function a process exit event handler)'); + // expect(result).toContain('--on-signal (Make the function a signal event handler)'); + // expect(result).toContain('--on-variable (Make the function a variable update event handler)'); + // expect(result).toContain('--wraps (Inherit completions from the given command)'); + // expect(true).toBeTruthy() +}); + +it('source and `function foo \\ --argument`', async () => { + const fishSource = new FishSource(); + // Does NOT Work!! + const result = await fishSource.sendLines(['function foo \\', '--argument']); + // expect(result).toContain('--argument-names (Specify named arguments)'); + // expect(result).toContain('--description (Set function description)'); + // expect(result).toContain('--inherit-variable (Snapshot and define local variable)'); + // expect(result).toContain('--no-scope-shadowing (Do not shadow variable scope of calling function)'); + // expect(result).toContain('--on-event (Make the function a generic event handler)'); + // expect(result).toContain('--on-job-exit (Make the function a job exit event handler)'); + // expect(result).toContain('--on-process-exit (Make the function a process exit event handler)'); + // expect(result).toContain('--on-signal (Make the function a signal event handler)'); + // expect(result).toContain('--on-variable (Make the function a variable update event handler)'); + // expect(result).toContain('--wraps (Inherit completions from the given command)'); + console.log(result); +}); + +// input.forEach(async line => { +// const tree = parser.parse(line); +// const {rootNode} = tree +// const lastLeaf = getLastLeaf(rootNode) +// +// const prev = findPreviousProcess(lastLeaf) +// +// console.log(`lastLeaf: '${lastLeaf.text}', type: ${lastLeaf.type}`); +// console.log(`process: '${prev?.text || 'null'}'`); +// +// console.log('____'); +// await parseCommandLine(line); + +// }); +// console.log({text: node.text, grammarId: node.grammarId, typeId: node.typeId, grammarType: node.grammarType, normalType: node.type, fieldName: parser.getLanguage().fieldNameForId(node.grammarId)}) +// console.log(); +// if (node.type === 'option' || node.type === 'argument') { +// console.log('Has argument!!!!'); +// } +// +// } +// console.log(); +// const res = await traverseCustom(line) +// console.log(res); +// console.log(parser.getLanguage()); + + + +// await parseCommandLine(line) +// console.log(); + +// const children = getChildNodes(rootNode); +// const lastChild = getLeafs(rootNode).pop()!; +// +// let current: SyntaxNode | null = lastChild; +// while (current && !startNodes.includes(current.type)) { +// current = current.previousSibling; +// } +// if (current) { +// console.log('commandNode', current.text, current.type); +// } + +// if (line.startsWith('function')) { + +// console.log('-'.repeat(Number.parseInt(process.env.COLUMNS || '80'))); +// console.log({line}); +// for (const child of children) { +// console.log({text: child.text, type: child.type, grammarType: child.grammarType}); +// } + +// console.log({ +// firstChild: rootNode.firstChild?.text || '', +// lastChild: rootNode.fieldNameForChild(-1) || '' +// }); +// const outputLine = inline.parseCommand(line) +// console.log(outputLine.command); + diff --git a/test-data/node-types.test.ts b/test-data/node-types.test.ts index e30b81f8..121f7c6f 100644 --- a/test-data/node-types.test.ts +++ b/test-data/node-types.test.ts @@ -1,9 +1,8 @@ import Parser, { Tree, SyntaxNode } from 'web-tree-sitter'; import { initializeParser } from '../src/parser'; -import { getChildNodes } from '../src/utils/tree-sitter'; +import { findFirstSibling, getChildNodes, getCommandArgumentValue } from '../src/utils/tree-sitter'; import * as NodeTypes from '../src/utils/node-types' import * as VarTypes from '../src/utils/variable-syntax-nodes' -import assert from 'node:assert' // import { assert } from 'chai'; import { expandEntireVariableLine } from '../src/utils/definition-scope' import { skip } from 'node:test'; @@ -25,6 +24,35 @@ function skipSetQuery(node: SyntaxNode) { 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; +} + + +function logNode(nodeName: string, text: string, type: string, isNamed: boolean) { + console.log({name: nodeName, text, type, isNamed}); + +} + function logNodes(nodes: SyntaxNode[]) { nodes.forEach(n => console.log(n.text)) } @@ -51,19 +79,28 @@ describe("node-types tests", () => { it('isCommand', () => { const commands = parseStringForNodeType('echo "hello world"', NodeTypes.isCommand); //logNodes(commands) - assert.equal(commands[0]?.text, 'echo "hello world"') + expect(commands[0]?.text).toEqual('echo "hello world"') }) it('isCommandName', () => { const commandsName = parseStringForNodeType('echo "hello world"', NodeTypes.isCommandName); //logNodes(commandsName) - assert.equal(commandsName[0]?.text, 'echo') + expect(commandsName[0]?.text).toEqual('echo') }) it('isComment', () => { const comments = parseStringForNodeType('# this is a comment', NodeTypes.isComment); //logNodes(comments) - assert.equal(comments[0]?.text, '# this is a comment') + 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', () => { @@ -76,16 +113,20 @@ describe("node-types tests", () => { const comments = parseStringForNodeType(testString, NodeTypes.isComment); //logNodes(shebang) //logNodes(comments) - assert.equal(shebang.length, 1) - assert.equal(comments.length, 2) + expect(shebang.length).toBe(1) + expect(comments.length).toBe(2) }) - it('isProgram', () => { - const input = 'echo "hello world"'; - const root = parser.parse(input).rootNode! - const program = parseStringForNodeType(input, NodeTypes.isProgram); - assert.equal(program[0]?.text, root.text) - }) + 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', () => { /** @@ -101,7 +142,7 @@ describe("node-types tests", () => { ].join('\n'); const statement = parseStringForNodeType(input, NodeTypes.isStatement); //logNodes(statement) - assert.equal(statement.length, 5) + expect(statement.length).toBe(5) }) it('isEnd', () => { @@ -114,7 +155,7 @@ describe("node-types tests", () => { ].join('\n'); const ends = parseStringForNodeType(input, NodeTypes.isEnd); //logNodes(ends) - assert.equal(ends.length, 5) + expect(ends.length).toBe(5) }) it('isString', () => { @@ -124,7 +165,7 @@ describe("node-types tests", () => { ].join('\n'); const strings = parseStringForNodeType(input, NodeTypes.isString); //logNodes(strings) - assert.equal(strings.length, 2) + expect(strings.length).toBe(2) }) it('isReturn', () => { @@ -135,7 +176,7 @@ describe("node-types tests", () => { ].join('\n') const returns = parseStringForNodeType(input, NodeTypes.isReturn); //logNodes(returns) - assert.equal(returns.length, 1) + expect(returns.length).toBe(1) }) /** @@ -153,7 +194,7 @@ describe("node-types tests", () => { ].join('\n') const functionDefinitions = parseStringForNodeType(input, NodeTypes.isFunctionDefinition); //logNodes(functionDefinitions) - assert.equal(functionDefinitions.length, 3) + expect(functionDefinitions.length).toBe(3) }) it('isFunctionDefinitionName', () => { @@ -168,8 +209,8 @@ describe("node-types tests", () => { ].join('\n') const functionDefinitionNames = parseStringForNodeType(input, NodeTypes.isFunctionDefinitionName); //logNodes(functionDefinitionNames) - assert.equal(functionDefinitionNames.length, 3) - assert.deepEqual(functionDefinitionNames.map(n => n.text), ['foo', 'foo_2', 'foo_2_inner']) + expect(functionDefinitionNames.length).toBe(3) + expect(functionDefinitionNames.map(n => n.text)).toEqual(['foo', 'foo_2', 'foo_2_inner']) }) // TODO @@ -183,8 +224,9 @@ describe("node-types tests", () => { 'set -gx OS_NAME (set -l f "v" | echo $v) # check for mac or linux', ].join('\n'); const variableDefinitions = parseStringForNodeType(input, NodeTypes.isDefinition); - assert.deepEqual( - variableDefinitions.map((v) => v.text), + expect( + variableDefinitions.map((v) => v.text) + ).toEqual( ["set_foo", "read_foo", "func_foo", "func_foo_arg", "OS_NAME", "f"] ); }) @@ -214,7 +256,517 @@ describe("node-types tests", () => { if (skipSetQuery(def)) return result.push(def) }) - assert.deepEqual(result.map(d => d.text), ['set_foo', 'param_foo', 'i', 'read_foo']) + 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, {shortOption: '-g'})), + ...parseStringForNodeType("set -gxa PATH $HOME/.cargo/bin", (n: SyntaxNode) => NodeTypes.isMatchingOption(n, {shortOption: '-x'})), + ...parseStringForNodeType("set -gxa PATH $HOME/.cargo/bin", (n: SyntaxNode) => NodeTypes.isMatchingOption(n, {shortOption: '-a'})) + ].map(n => n.text)).toEqual(['-gxa', '-gxa', '-gxa']) + + const oldFlag = parseStringForNodeType('find -type d', (n: SyntaxNode) => NodeTypes.isMatchingOption(n, {oldUnixOption: '-type'})) + expect(oldFlag.map(n => n.text)).toEqual(['-type']) + + expect( + parseStringForNodeType( + 'set --global PATH /bin', + (n: SyntaxNode) => NodeTypes.isMatchingOption(n, {longOption: '--global'}) + ).map(n => n.text) + ).toEqual(['--global']) + + const longOpt = parseStringForNodeType("command ls --ignore='install_scripts'", (n: SyntaxNode) => NodeTypes.isMatchingOption(n, {longOption: '--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, {shortOption: '-r', longOption: '--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, {shortOption: '-z'}) + case 'string': + return NodeTypes.isMatchingOption(node, {shortOption: '-f', longOption: '--field'}) + case 'abbr': + return NodeTypes.isMatchingOption(node, {longOption: '--set-cursor'}) + default: + return null + } + } + + optValues.forEach(o => { + // console.log(o.text); + const parentCmd = NodeTypes.findParentCommand(o)?.firstNamedChild + if (!parentCmd) { + console.log('ERRROR:', 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); }) -}) \ No newline at end of file + }) +}) diff --git a/test-data/parser.test.ts b/test-data/parser.test.ts index 2e21ef80..e9c22328 100644 --- a/test-data/parser.test.ts +++ b/test-data/parser.test.ts @@ -1,24 +1,104 @@ -import Parser from "web-tree-sitter"; +import Parser, { Language } from "web-tree-sitter"; // import {initializeParser} from '../src/parser' //import fish from 'tree-sitter-fish' -import {setLogger} from './helpers' -import { initializeParser } from '../src/parser'; +import { setLogger } from './helpers'; +import { initializeParser } from '../src/parser'; -setLogger() + +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('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(103); + // console.log(lang); + }); + + 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); + } + } + }); +}); \ No newline at end of file diff --git a/test-data/semantic-tokens.test.ts b/test-data/semantic-tokens.test.ts new file mode 100644 index 00000000..721482d9 --- /dev/null +++ b/test-data/semantic-tokens.test.ts @@ -0,0 +1,27 @@ +import { SemanticTokenTypes } from 'vscode-languageserver'; + + +const tokenTypes: string[] = [ + SemanticTokenTypes.namespace, + SemanticTokenTypes.class, + SemanticTokenTypes.enum, + SemanticTokenTypes.interface, + SemanticTokenTypes.struct, + SemanticTokenTypes.typeParameter, + SemanticTokenTypes.type, + SemanticTokenTypes.parameter, + SemanticTokenTypes.variable, + SemanticTokenTypes.property, + SemanticTokenTypes.enumMember, + SemanticTokenTypes.event, + SemanticTokenTypes.function, + SemanticTokenTypes.method, + SemanticTokenTypes.macro, + SemanticTokenTypes.keyword, + SemanticTokenTypes.modifier, + SemanticTokenTypes.comment, + SemanticTokenTypes.string, + SemanticTokenTypes.number, + SemanticTokenTypes.regexp, + SemanticTokenTypes.operator, +]; diff --git a/test-data/signature.test.ts b/test-data/signature.test.ts new file mode 100644 index 00000000..9c0ddf32 --- /dev/null +++ b/test-data/signature.test.ts @@ -0,0 +1,224 @@ +import Parser, { SyntaxNode, Tree, Point } from 'web-tree-sitter'; +import { initializeParser } from '../src/parser'; +import { ExtendedBaseJson, PrebuiltDocumentationMap } from '../src/utils/snippets' +import * as NodeTypes from '../src/utils/node-types' +import * as TreeSitter from '../src/utils/tree-sitter' +import { getAliasedCompletionItemSignature, getDefaultSignatures, lineSignatureBuilder, regexStringSignature } from '../src/signature' +import {setLogger} from './helpers' +import { MarkupContent, SignatureHelp, SignatureInformation } from 'vscode-languageserver'; +import { CompletionItemMap } from '../src/utils/completion/startup-cache'; +import { FishAliasCompletionItem } from '../src/utils/completion/types'; +import { getDocumentationResolver } from '../src/utils/completion/documentation'; + +let parser: Parser; +let completionMap: CompletionItemMap +// const documentationMap = PrebuiltDocumentationMap; + +function analyzerParseCurrentLine(input: string) { + const line = input.trim(); + const rootNode = parser.parse(line).rootNode + const lastNode = rootNode.descendantForPosition({row: 0, column: line.length - 1}) + const wordAtPoint = rootNode.descendantForPosition({row: 0, column: Math.max(line.length - 1, 0)}) + return { + line: input, + word: wordAtPoint, + lineRootNode: rootNode, + lineCurrentNode: lastNode + } +} + + + +setLogger( + async () => { + parser = await initializeParser(); + completionMap = await CompletionItemMap.initialize() + }, + async () => { + if (parser) parser.delete(); + } +) + +function lineSignatureBuilder(lineRootNode: SyntaxNode, lineCurrentNode: SyntaxNode): SignatureHelp | null { + const currentCmd = NodeTypes.findParentCommand(lineCurrentNode); + const pipes = getPipes(lineRootNode); + const varNode = getVariableNode(lineRootNode); + const allCmds = getAllCommands(lineRootNode); + const regexOption = getRegexOption(lineRootNode); + + if (!currentCmd && pipes.length === 1) return getPipesSignature(pipes); + + switch (true) { + case currentCmd && 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 currentCmd && + case currentCmd && allCmds.length === 1: + return getCommandSignature(currentCmd); + + default: + return null; + } +} + +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, { shortOption: '-r', longOption: '--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, + }; +} + +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, + }; +} +describe('signature test-suite', () => { + + it('`variable` signature from snippets/*.json', () => { + const { line, lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('set -gx fish_lsp_enabled_handlers') + const signature = lineSignatureBuilder(lineRootNode, lineCurrentNode, completionMap)! + // console.log(JSON.stringify(signature, null, 2)); + expect(signature.signatures[0]!.label).toBe('fish_lsp_enabled_handlers') + }) + + it('`function` signature from snippets/*.json', () => { + const { line, lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('fish_prompt ') + const signature = lineSignatureBuilder(lineRootNode, lineCurrentNode, completionMap)! + + // console.log(JSON.stringify(signature, null, 2)); + expect(signature.signatures[0]!.label).toBe('fish_prompt') + }) + + it('`pipes` signature from snippets/*.json', () => { + const { line, lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('alias 2>> ') + const signature = lineSignatureBuilder(lineRootNode, lineCurrentNode, completionMap)! + + // console.log(JSON.stringify(signature, null, 2)); + expect(signature.signatures[0]!.label).toBe('2>>') + + }) + + it('`return $status` from snippets/*.json', () => { + const { lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('return '); + const signature = lineSignatureBuilder(lineRootNode, lineCurrentNode, completionMap)!; + console.log(JSON.stringify(signature, null, 2)); + expect(signature.signatures[0]!.label).toEqual('$status'); + }) + + it('`string --regex _`', () => { + const { line, lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('if string match -re "^-.*" "$argv"') + const currentCmd = NodeTypes.findParentCommand(lineCurrentNode)! + + // console.log(currentCmd.text) + const signature = lineSignatureBuilder(currentCmd, lineCurrentNode, completionMap)! + // console.log(JSON.stringify(signature, null, 2)); + expect(signature.signatures.length).toBe(2) + }) + + + // it('`function NAME --argument-names a b`', async () => { + // const completionMap = await CompletionItemMap.initialize() + // const fn = completionMap.allOfKinds('function').find(f => f.label === 'popd_duplicates')! + // const documentation = await getDocumentationResolver(fn) + // const signature = buildSignature(fn.label, documentation.value) + // console.log(signature); + // }) + + it('`alias NAME`', async () => { + const completionMap = await CompletionItemMap.initialize() + const aliases = completionMap.allOfKinds('alias') + console.log(JSON.stringify(getAliasedCompletionItemSignature(aliases.find(a => a.label === 'vimdiff')!), null, 2)); + }) + + it('updates `activeSignature` and `activeParameter`', () => { + const { lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('set -gx fish_lsp_enabled_handlers value1 value2'); + const signature = lineSignatureBuilder(lineRootNode, lineCurrentNode, completionMap)!; + signature.activeSignature = 1; // Focusing on the second signature if multiple + signature.activeParameter = 2; // Focusing on the third parameter + expect(signature.activeSignature).toBe(1); + expect(signature.activeParameter).toBe(2); + }); + + it('does not show test command if set is the most recent', () => { + const { lineRootNode, lineCurrentNode } = analyzerParseCurrentLine('if test -n $argv; and set -q CMD_DURATION'); + const currentCmd = NodeTypes.findParentCommand(lineCurrentNode)! + const signature = lineSignatureBuilder(currentCmd, lineCurrentNode, completionMap)!; + console.log(JSON.stringify(signature, null, 2)); + expect(signature.signatures[0]!.label).toBe('CMD_DURATION'); // Expecting the `set` command signature + }); + +}) + + diff --git a/test-data/tree-sitter.test.ts b/test-data/tree-sitter.test.ts new file mode 100644 index 00000000..17cb8985 --- /dev/null +++ b/test-data/tree-sitter.test.ts @@ -0,0 +1,356 @@ +import Parser, { SyntaxNode, Tree, Point } from 'web-tree-sitter'; +import { + getChildNodes, + getNamedChildNodes, + findChildNodes, + getParentNodes, + findFirstParent, + getSiblingNodes, + findFirstNamedSibling, + findFirstSibling, + findEnclosingScope, + getNodeText, + getNodesTextAsSingleLine, + firstAncestorMatch, + ancestorMatch, + descendantMatch, + hasNode, + getNamedNeighbors, + getRange, + findNodeAt, + equalRanges, + getNodeAt, + getNodeAtRange, + positionToPoint, + pointToPosition, + rangeToPoint, + getRangeWithPrecedingComments, + getPrecedingComments, + isFishExtension, + isPositionWithinRange, + isPositionAfter, + isNodeWithinRange, + getLeafs, + getLastLeaf, + matchesArgument, + getCommandArgumentValue +} 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; + + test('getChildNodes returns all child nodes', () => { + mockRootNode = parseString('set -gx a "1" "2" "3"').rootNode; + const result = getChildNodes(mockRootNode); + expect(result.length).toBe(15); + }); + + test('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' + ]); + }); + test('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' ]); + }); + + test('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' ]); + }); + + test('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"'); + }); + + test('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"' ]); + }); + + test('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'); + }); + + test('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'); + }); + + test('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'); + }); + + test('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 + // }); + // + test('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'); + let 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"'); + }); + + test('ancestorMatch returns all matching ancestor nodes', () => { + let node = parseStringForNode('set -gx a "1" "2" "3"', (n: SyntaxNode) => n.text === '"3"').pop()!; + let result = ancestorMatch(node, NodeTypes.isOption, false); + expect(result.map(n => n.text)).toEqual([ + '-gx', + '-gx' + ]); + }); + + test('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' ]); + }); + + test('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(); + }); + + test('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"' ]); + }); + + test('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 } }); + }); + + test('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'); + }); + // + test('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(); + }); + + test('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'); + }); + + test('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); + }); + + test('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 + }); + }); + + test('pointToPosition converts point to position', () => { + const point = { row: 0, column: 1 }; + const result = pointToPosition(point); + expect(result).toEqual({ + line: 0, + character: 1 + }); + }); + + test('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 + }); + }); + + // test('getRangeWithPrecedingComments returns range with preceding comments', () => { + // const result = getRangeWithPrecedingComments(mockRootNode); + // // Add assertions here + // }); + // + // test('getPrecedingComments returns preceding comments', () => { + // const result = getPrecedingComments(mockRootNode); + // // Add assertions here + // }); + // + test('isFishExtension checks if path has fish extension', () => { + const result = isFishExtension('file:///home/user/.config/fish/functions/test.fish'); + expect(result).toBeTruthy(); + }); + + test('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(); + }); + + test('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(); + }); + + test('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(); + }); + + test('getLeafs returns leaf nodes', () => { + const tree = parseString('set -gx a "1" "2" "3"'); + const rootNode = tree!.rootNode; + const result = getLeafs(rootNode); + + expect(result.map(m => m.text)).toEqual([ + 'set', '-gx', 'a', + '"', '"', '"', + '"', '"', '"' + ]); + }); + + test('getLastLeaf returns last leaf node', () => { + const tree = parseString('set -gx a "1" "2" "3"'); + const rootNode = tree!.rootNode; + const result = getLastLeaf(rootNode); + expect(result.text).toEqual('"'); + }); + + // 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); + // }) + // + // }); + // test('matchesArgument checks if node matches argument', () => { + // const result = matchesArgument(mockRootNode, 'arg'); + // // Add assertions here + // }); + // + // test('getCommandArgumentValue returns command argument value', () => { + // const result = getCommandArgumentValue(mockRootNode, 'arg'); + // // Add assertions here + // }); +}); diff --git a/tsconfig.json b/tsconfig.json index e71085d7..04ce76bf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,6 @@ "@package": ["./package.json"], // "@snippets/*": ["./snippets/*"], // "@src/*": ["./src/*"], - "@utils/*": ["./src/utils/*"] } }, "include": [ @@ -23,4 +22,4 @@ "node_modules", "out" ] -} \ No newline at end of file +}