├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── KNOWNISSUES.md ├── LICENSE ├── README.md ├── build-release.sh ├── client ├── LICENSE ├── package-lock.json ├── package.json ├── src │ ├── command-handler.ts │ ├── extension.ts │ ├── migration-helper.ts │ ├── migrations │ │ └── python-keywords.ts │ ├── rf-server-client.ts │ └── utils │ │ └── config.ts └── tsconfig.json ├── design ├── ast-hierarchy.mdj ├── ast-hierarchy.png ├── data-models.mdj ├── data-models.png ├── extension-behaviour.drawio └── extension-behaviour.png ├── icon.png ├── library-docs ├── .prettierignore ├── .prettierrc.json ├── fetch-stds.sh ├── library-docs │ ├── BuiltIn-2.7.7.json │ ├── BuiltIn-2.8.7.json │ ├── BuiltIn-2.9.2.json │ ├── BuiltIn-3.0.4.json │ ├── BuiltIn-3.1.1.json │ ├── Collections-2.7.7.json │ ├── Collections-2.8.7.json │ ├── Collections-2.9.2.json │ ├── Collections-3.0.4.json │ ├── Collections-3.1.1.json │ ├── DateTime-2.8.7.json │ ├── DateTime-2.9.2.json │ ├── DateTime-3.0.4.json │ ├── DateTime-3.1.1.json │ ├── Dialogs-2.7.7.json │ ├── Dialogs-2.8.7.json │ ├── Dialogs-2.9.2.json │ ├── Dialogs-3.0.4.json │ ├── Dialogs-3.1.1.json │ ├── OperatingSystem-2.7.7.json │ ├── OperatingSystem-2.8.7.json │ ├── OperatingSystem-2.9.2.json │ ├── OperatingSystem-3.0.4.json │ ├── OperatingSystem-3.1.1.json │ ├── Process-2.8.7.json │ ├── Process-2.9.2.json │ ├── Process-3.0.4.json │ ├── Process-3.1.1.json │ ├── Screenshot-2.7.7.json │ ├── Screenshot-2.8.7.json │ ├── Screenshot-2.9.2.json │ ├── Screenshot-3.0.4.json │ ├── Screenshot-3.1.1.json │ ├── Selenium2Library-1.8.0.json │ ├── Selenium2Library-3.0.0.json │ ├── SeleniumLibrary-3.2.0.json │ ├── SeleniumLibrary-3.3.1.json │ ├── String-2.7.7.json │ ├── String-2.8.7.json │ ├── String-2.9.2.json │ ├── String-3.0.4.json │ ├── String-3.1.1.json │ ├── Telnet-2.7.7.json │ ├── Telnet-2.8.7.json │ ├── Telnet-2.9.2.json │ ├── Telnet-3.0.4.json │ ├── Telnet-3.1.1.json │ ├── XML-2.8.7.json │ ├── XML-2.9.2.json │ ├── XML-3.0.4.json │ └── XML-3.1.1.json ├── package-lock.json ├── package.json ├── src │ └── fetch-library-documentation.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── robot-language-configuration.json ├── server ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── README.md ├── TODO.md ├── package-lock.json ├── package.json ├── src │ ├── intellisense │ │ ├── completion-provider.ts │ │ ├── completion-provider │ │ │ ├── completion-helper.ts │ │ │ ├── completion-providers.ts │ │ │ ├── functions-table-completions.ts │ │ │ ├── keyword-table-completions.ts │ │ │ ├── settings-table-completions.ts │ │ │ ├── suite-completions.ts │ │ │ ├── testcase-table-completions.ts │ │ │ └── variable-table-completions.ts │ │ ├── definition-finder.ts │ │ ├── formatters.ts │ │ ├── highlight-provider.ts │ │ ├── keyword-matcher.ts │ │ ├── models.ts │ │ ├── node-locator.ts │ │ ├── reference-finder.ts │ │ ├── search-tree.ts │ │ ├── symbol-provider.ts │ │ ├── test │ │ │ ├── data │ │ │ │ └── definition-finder.data.ts │ │ │ ├── definition-finder.test.ts │ │ │ └── keyword-matcher.test.ts │ │ ├── type-guards.ts │ │ └── workspace │ │ │ ├── library.ts │ │ │ ├── python-file.ts │ │ │ ├── robot-file.ts │ │ │ ├── workspace-file.ts │ │ │ └── workspace.ts │ ├── logger.ts │ ├── parser │ │ ├── function-parsers.ts │ │ ├── keywords-table-parser.ts │ │ ├── models.ts │ │ ├── parser.ts │ │ ├── position-helper.ts │ │ ├── primitive-parsers.ts │ │ ├── row-iterator.ts │ │ ├── setting-parser.ts │ │ ├── settings-table-parser.ts │ │ ├── table-models.ts │ │ ├── table-reader.ts │ │ ├── test-cases-table-parser.ts │ │ ├── test │ │ │ ├── function-parsers.test.ts │ │ │ ├── keywords-table-parser.test.ts │ │ │ ├── parser.test.ts │ │ │ ├── primitive-parsers.test.ts │ │ │ ├── setting-parser.test.ts │ │ │ ├── settings-table-parser.test.ts │ │ │ ├── table-reader.test.ts │ │ │ ├── test-cases-table-parser.test.ts │ │ │ ├── test-helper.ts │ │ │ ├── variable-parsers.test.ts │ │ │ └── variables-table-parser.test.ts │ │ ├── variable-parsers.ts │ │ └── variables-table-parser.ts │ ├── python-parser │ │ ├── python-parser.ts │ │ └── test │ │ │ └── python-parser.test.ts │ ├── server.ts │ ├── thenable.d.ts │ ├── traverse │ │ └── traverse.ts │ └── utils │ │ ├── ast-util.ts │ │ ├── position.ts │ │ └── settings.ts ├── test-data │ ├── resources │ │ ├── common_resources.robot │ │ ├── production.robot │ │ └── smoke_resources.robot │ └── smoke.robot └── tsconfig.json ├── syntaxes └── robot.tmLanguage ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - VSCode version: 28 | - Extension version: 29 | - OS: [e.g. Linux/Mac/Win] 30 | 31 | **Extension output:** 32 | Please include the extension output with debug logging turned on. 33 | 1. Add setting `"rfLanguageServer.logLevel": "debug"` 34 | 1. Select View -> Output -> Robot Framework Intellisense Server 35 | 1. Reproduce the issue 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature-request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | out 40 | node_modules 41 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "parser": "typescript", 5 | "printWidth": 80, 6 | "semi": true, 7 | "singleQuote": false, 8 | "tabWidth": 2, 9 | "trailingComma": "es5", 10 | "useTabs": false 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "16" 4 | script: 5 | - npm run lint 6 | - npm run test 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 11 | "stopOnEntry": false, 12 | "sourceMaps": true, 13 | "outFiles": ["${workspaceRoot}/client/out/**/*.js"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true, // set this to false to include "out" folder in search results 8 | "server": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "build", 9 | "group": "build", 10 | "problemMatcher": ["$tsc-watch"], 11 | "isBackground": true 12 | }, 13 | { 14 | "type": "npm", 15 | "script": "lint", 16 | "problemMatcher": ["$tslint5"] 17 | }, 18 | { 19 | "type": "npm", 20 | "script": "watch", 21 | "isBackground": true, 22 | "group": { 23 | "kind": "build", 24 | "isDefault": true 25 | }, 26 | "presentation": { 27 | "panel": "dedicated", 28 | "reveal": "never" 29 | }, 30 | "problemMatcher": ["$tsc-watch"] 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | out/ 4 | src/ 5 | tsconfig.json 6 | webpack.config.js 7 | .gitignore 8 | tslint.json 9 | .editorconfig 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at tomi.turtiainen@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to Robot Framework Language Server 2 | 3 | ## Developing the extension locally 4 | 5 | 1. Install dependencies `npm ci` 6 | 2. Start the client & server build process in watch mode with `npm run build` or using VSCode task `Cmd+Shift+B` 7 | 3. Use the "Launch Extension" launch configuration in VSCode to run the extension in debug mode 8 | 1. Go to "Run and Debug" view (`Cmd+Shift+D`) 9 | 2. Select "Launch Extension" from the dropdown 10 | 3. Press ▷ to launch the config 11 | 12 | ## Packaging the extension into installation package 13 | 14 | The extension can be packaged into an `.vsix` file that can be installed into VSCode: 15 | 16 | 1. Make sure you have [vsce](https://github.com/microsoft/vscode-vsce) installed: `npm install --global @vscode/vsce` 17 | 2. Run `npm run package` 18 | 3. The `.vsix` can be installed from VSCode extensions panel's `...` menu: "Install from VSIX..." 19 | 20 | ## Did you find a bug? 21 | 22 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/tomi/vscode-rf-language-server/issues). 23 | 24 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/tomi/vscode-rf-language-server/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 25 | 26 | * If possible, use the relevant bug report templates to create the issue. 27 | 28 | ## Did you write a patch that fixes a bug? 29 | 30 | * Open a new GitHub pull request with the patch. 31 | 32 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 33 | 34 | * Before submitting, please ensure that 35 | 36 | 1. The code has been formatted and passes linting (`npm run lint`) 37 | 2. A test case has been added and it passes (`npm run test`) 38 | 39 | ## Do you intend to add a new feature or change an existing one? 40 | 41 | * Suggest your change by creating an issue (unless one exists already) on [the issues page](https://github.com/tomi/vscode-rf-language-server/issues) and start writing code. 42 | 43 | * Open a new GitHub pull request with the patch. 44 | 45 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 46 | 47 | * Before submitting, please ensure that 48 | 49 | 1. The code has been formatted and passes linting (`npm run lint`) 50 | 2. A test case has been added and it passes (`npm run test`) 51 | 52 | ## Do you have questions about the source code? 53 | 54 | * Ask any question on [the issues page](https://github.com/tomi/vscode-rf-language-server/issues). 55 | 56 | The extension is being developed solely on the author's free time. I encourage you to contribute if you want to see a specific feature added! 57 | 58 | Thanks! :heart: :heart: :heart: 59 | 60 | Tomi Turtiainen 61 | -------------------------------------------------------------------------------- /KNOWNISSUES.md: -------------------------------------------------------------------------------- 1 | # Known Issues 2 | 3 | * Test case templates are not parsed correctly 4 | * For loops are not parsed correctly 5 | * Can't find keywords defined in java libraries 6 | * Gherkin style is not yet supported 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Tomi Turtiainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 0 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Robot Framework Intellisense 2 | 3 | A [Visual Studio Code](https://code.visualstudio.com/) extension that supports Robot Framework development. 4 | 5 | ## Features 6 | 7 | ### Syntax highlighting 8 | * Supports `.robot` and `.resource` files 9 | * Can be added for `.txt` files using the `files.associations` setting: 10 | ```json 11 | "files.associations": { 12 | "*.txt": "robot" 13 | } 14 | ``` 15 | 16 | ### Goto definition 17 | * For variables 18 | * For user keywords 19 | * `F12` on Mac, Linux and Windows 20 | 21 | ### Find all references 22 | * For user keywords 23 | * `⇧F12` on Mac, `Shift+F12` on Linux and Windows 24 | 25 | ### List file symbols 26 | * Shows variables, keywords and test cases 27 | * `⇧⌘O` on Mac, `Ctrl+Shift+O` on Linux and Windows 28 | 29 | ### List workspace symbols 30 | * Shows variables, keywords and test cases 31 | * `⌘T` on Mac, `Ctrl+T` on Linux and Windows 32 | 33 | ### Highlight All Occurrences of a Symbol in a Document 34 | * Highlights all occurrences of a variable, keyword or setting 35 | * Move the cursor to a variable, keyword or setting 36 | 37 | ### Show Code Completion Proposals 38 | * Suggests user keywords and variables 39 | * `⌃Space` on Mac, `Ctrl+Space` on Linux and Windows 40 | 41 | ### Code Completion for Standard Library Keywords 42 | * Code completion for keywords in the standard libraries, like `BuiltIn` and `String` 43 | * Requires configuration for which libraries are suggested with `rfLanguageServer.libraries` setting. E.g. 44 | ```json 45 | "rfLanguageServer.libraries": [ 46 | "BuiltIn-3.0.4" 47 | ] 48 | ``` 49 | * See a list of all available libraries [here](#supported-standard-libraries) 50 | 51 | ### Code Completion for Any 3rd Party Library 52 | * See [defining 3rd party libraries](#defining-3rd-party-libraries) 53 | 54 | ### Support for python keywords 55 | * Keywords defined in `.py` files are also included 56 | * Requires that the `.py` files are included with `rfLanguageServer.includePaths` setting. E.g. 57 | ```json 58 | "rfLanguageServer.includePaths": [ 59 | "**/*.robot", 60 | "**/*.py" 61 | ] 62 | ``` 63 | 64 | ## Configuration 65 | 66 | By default all `.robot` and `.resource` files are parsed. This can be configured using parameters. (see `Code` > `Preferences` > `Workspace Settings`). 67 | 68 | |param | description | 69 | |---------------------------------|--------------------------| 70 | | `rfLanguageServer.includePaths` | Array of glob patterns for files to be included`| 71 | | `rfLanguageServer.excludePaths` | Array of glob patterns for files to be excluded| 72 | | `rfLanguageServer.logLevel` | What information of the language server is logged in the Output. Possible values `off`, `errors`, `info`, `debug`| 73 | | `rfLanguageServer.trace.server` | what information of the communication between VSCode and the rfLanguageServer is logged to the Output. Possible values `off`, `messages`, `verbose`| 74 | | `rfLanguageServer.libraries` | What libraries' keywords are suggested with code completion. Can be a name of a standard library (see [Supported standard libraries](#supported-standard-libraries)) or a library definition (see [defining 3rd party libraries](#defining-3rd-party-libraries)) or a combination of them. | 75 | 76 | The `includePaths` and `excludePaths` properties take a list of glob-like file patterns. Even though any files can be matched this way, only files with supported extensions are included (i.e. `.robot`, `.resource`, `.txt`, and `.py`). 77 | 78 | If the `includePaths` is left unspecified, the parser defaults to including all `.robot` and `.resource` files in the containing directory and subdirectories except those excluded using the `excludePaths` property. 79 | 80 | ## Supported standard libraries 81 | 82 | * `BuiltIn-2.7.7` 83 | * `BuiltIn-2.8.7` 84 | * `BuiltIn-2.9.2` 85 | * `BuiltIn-3.0.4` 86 | * `Collections-2.7.7` 87 | * `Collections-2.8.7` 88 | * `Collections-2.9.2` 89 | * `Collections-3.0.4` 90 | * `DateTime-2.8.7` 91 | * `DateTime-2.9.2` 92 | * `DateTime-3.0.4` 93 | * `Dialogs-2.7.7` 94 | * `Dialogs-2.8.7` 95 | * `Dialogs-2.9.2` 96 | * `Dialogs-3.0.4` 97 | * `OperatingSystem-2.7.7` 98 | * `OperatingSystem-2.8.7` 99 | * `OperatingSystem-2.9.2` 100 | * `OperatingSystem-3.0.4` 101 | * `Process-2.8.7` 102 | * `Process-2.9.2` 103 | * `Process-3.0.4` 104 | * `Screenshot-2.7.7` 105 | * `Screenshot-2.8.7` 106 | * `Screenshot-2.9.2` 107 | * `Screenshot-3.0.4` 108 | * `Selenium2Library-1.8.0` 109 | * `Selenium2Library-3.0.0` 110 | * `SeleniumLibrary-3.2.0` 111 | * `String-2.7.7` 112 | * `String-2.8.7` 113 | * `String-2.9.2` 114 | * `String-3.0.4` 115 | * `Telnet-2.7.7` 116 | * `Telnet-2.8.7` 117 | * `Telnet-2.9.2` 118 | * `Telnet-3.0.4` 119 | * `XML-2.8.7` 120 | * `XML-2.9.2` 121 | * `XML-3.0.4` 122 | 123 | ## Defining 3rd party libraries 124 | 125 | 3rd party libraries can be defined inline in the `rfLanguageServer.libraries` configuration block. For example: 126 | 127 | ```json 128 | "rfLanguageServer.libraries": [ 129 | { 130 | "name": "MyLibrary", 131 | "version": "1.0.0", 132 | "keywords": [ 133 | { "name": "My Keyword 1", "args": ["arg1"], "doc": "documentation" }, 134 | { "name": "My Keyword 2", "args": [], "doc": "documentation" } 135 | ] 136 | } 137 | ] 138 | ``` 139 | 140 | ## Known issues 141 | 142 | Can be found [here](https://github.com/tomi/vscode-rf-language-server/blob/master/client/KNOWNISSUES.md) 143 | 144 | ## Changelog 145 | 146 | Can be found [here](https://github.com/tomi/vscode-rf-language-server/blob/master/client/CHANGELOG.md). 147 | 148 | ## Bugs 149 | 150 | Report them [here](https://github.com/tomi/vscode-rf-language-server/issues). 151 | 152 | ## Contributing 153 | 154 | All contributions are welcomed! Please see [the contributing guide](https://github.com/tomi/vscode-rf-language-server/blob/master/CONTRIBUTING.md) for more details. 155 | 156 | ## License 157 | 158 | [MIT](https://github.com/tomi/vscode-rf-language-server/blob/master/LICENSE) 159 | 160 | 161 | ## Acknowledgements 162 | 163 | This project is a grateful recipient of the [Futurice Open Source sponsorship program](https://spiceprogram.org). ♥ 164 | 165 | Syntax highlighting grammar is built on top of [work](https://bitbucket.org/jussimalinen/robot.tmbundle/wiki/Home) by Jussi Malinen. 166 | -------------------------------------------------------------------------------- /build-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd server 4 | echo "Building server" 5 | rm -rf ./node_modules 6 | npm ci 7 | npm run clean 8 | npm run compile 9 | 10 | echo "Building client" 11 | cd ../client 12 | rm -rf ./node_modules 13 | npm ci 14 | vsce package 15 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Tomi Turtiainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "2.8.0", 4 | "license": "MIT", 5 | "engines": { 6 | "vscode": "^1.70.0" 7 | }, 8 | "scripts": { 9 | "clean": "rm -rf ./out", 10 | "compile": "tsc -watch -p ./", 11 | "lint": "npm run tslint && npm run prettier", 12 | "prettier": "prettier -l src/**", 13 | "test": "echo no tests", 14 | "tslint": "tslint --project . --config ../tslint.json", 15 | "watch": "tsc -watch -p ./" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "16.18.55", 19 | "@types/vscode": "^1.82.0", 20 | "prettier": "3.0.3", 21 | "tslint": "6.1.3", 22 | "typescript": "5.2.2" 23 | }, 24 | "dependencies": { 25 | "vscode-languageclient": "9.0.1" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/tomi/vscode-rf-language-server.git" 30 | }, 31 | "author": "Tomi Turtiainen " 32 | } 33 | -------------------------------------------------------------------------------- /client/src/command-handler.ts: -------------------------------------------------------------------------------- 1 | import { window, workspace } from "vscode"; 2 | 3 | import { Config } from "./utils/config"; 4 | import RFServerClient from "./rf-server-client"; 5 | import { exec } from "child_process"; 6 | 7 | function getIncludeExcludePattern() { 8 | return { 9 | include: Config.getInclude(), 10 | exclude: Config.getExclude(), 11 | }; 12 | } 13 | 14 | export default class CommandHandler { 15 | private langClient: RFServerClient; 16 | 17 | constructor(languageClient: RFServerClient) { 18 | this.langClient = languageClient; 19 | } 20 | 21 | public parseAll() { 22 | const includeExclude = getIncludeExcludePattern(); 23 | 24 | if (!workspace.rootPath) { 25 | // Not a folder 26 | const activeEditor = window.activeTextEditor; 27 | if (!activeEditor) { 28 | return; 29 | } 30 | 31 | this.langClient.sendBuildFilesRequest([activeEditor.document.uri.fsPath]); 32 | } else { 33 | workspace 34 | .findFiles(includeExclude.include, includeExclude.exclude) 35 | .then(files => { 36 | const filePaths = files.map(fileUri => fileUri.fsPath); 37 | 38 | // Send the array of paths to the language server 39 | this.langClient.sendBuildFilesRequest(filePaths); 40 | }); 41 | } 42 | } 43 | 44 | public reportBug() { 45 | _openLinkInBrowser( 46 | "https://github.com/tomi/vscode-rf-language-server/issues" 47 | ); 48 | } 49 | } 50 | 51 | function _openLinkInBrowser(url: string) { 52 | let openCommand: string = ""; 53 | 54 | switch (process.platform) { 55 | case "darwin": 56 | case "linux": 57 | openCommand = "open "; 58 | break; 59 | case "win32": 60 | openCommand = "start "; 61 | break; 62 | default: 63 | return; 64 | } 65 | 66 | exec(openCommand + url); 67 | } 68 | -------------------------------------------------------------------------------- /client/src/extension.ts: -------------------------------------------------------------------------------- 1 | import { commands, workspace, ExtensionContext } from "vscode"; 2 | import CommandHandler from "./command-handler"; 3 | import RFServerClient from "./rf-server-client"; 4 | import { Config } from "./utils/config"; 5 | import { runMigrations } from "./migration-helper"; 6 | 7 | let rfLanguageServerClient: RFServerClient; 8 | let commandHandler: CommandHandler; 9 | 10 | export function activate(context: ExtensionContext) { 11 | rfLanguageServerClient = new RFServerClient(context); 12 | commandHandler = new CommandHandler(rfLanguageServerClient); 13 | 14 | runMigrations(); 15 | 16 | context.subscriptions.push( 17 | commands.registerCommand( 18 | "rfIntellisense.reportBug", 19 | commandHandler.reportBug 20 | ) 21 | ); 22 | context.subscriptions.push( 23 | commands.registerCommand("rfIntellisense.rebuildSources", () => 24 | commandHandler.parseAll() 25 | ) 26 | ); 27 | 28 | rfLanguageServerClient.start().then(() => commandHandler.parseAll()); 29 | 30 | let currentIncludePattern = Config.getInclude(); 31 | const disposable = workspace.onDidChangeConfiguration(() => { 32 | const newIncludePattern = Config.getInclude(); 33 | if (currentIncludePattern === newIncludePattern) { 34 | return; 35 | } 36 | 37 | currentIncludePattern = newIncludePattern; 38 | console.log("Configuration has changed. Restarting language server.."); 39 | rfLanguageServerClient.restart().then(() => commandHandler.parseAll()); 40 | }); 41 | 42 | // Push the disposable to the context's subscriptions so that the 43 | // client can be deactivated on extension deactivation 44 | context.subscriptions.push(rfLanguageServerClient); 45 | context.subscriptions.push(disposable); 46 | } 47 | 48 | export function deactivate(): Thenable | undefined { 49 | if (!rfLanguageServerClient) { 50 | return undefined; 51 | } 52 | return rfLanguageServerClient.stop(); 53 | } 54 | -------------------------------------------------------------------------------- /client/src/migration-helper.ts: -------------------------------------------------------------------------------- 1 | import checkPythonKeywords from "./migrations/python-keywords"; 2 | 3 | export function runMigrations() { 4 | checkPythonKeywords(); 5 | } 6 | -------------------------------------------------------------------------------- /client/src/migrations/python-keywords.ts: -------------------------------------------------------------------------------- 1 | import { window, WorkspaceConfiguration } from "vscode"; 2 | import { Config } from "../utils/config"; 3 | 4 | const YES = "Yes"; 5 | const NO = "No"; 6 | const PY_CONFIG_KEY = "pythonKeywords"; 7 | const INCLUDE_CONFIG_KEY = "includePaths"; 8 | 9 | export default async function checkShouldMigrate() { 10 | return _checkPythonKeywordsUsage(); 11 | } 12 | 13 | async function _checkPythonKeywordsUsage() { 14 | const config = Config.getSettings(); 15 | 16 | if (!config) { 17 | return; 18 | } 19 | 20 | const inspectResult = config.inspect(PY_CONFIG_KEY); 21 | const hasLocal = inspectResult.workspaceValue === true; 22 | const hasGlobal = inspectResult.globalValue === true; 23 | if (hasLocal || hasGlobal) { 24 | const result = await _promptShouldUpdate(); 25 | if (result === YES) { 26 | _replacePythonKeywords(config, hasLocal, hasGlobal); 27 | const infoMsg = `**pythonKeywords** setting has been replaced with a '\\*\\*/*.py' include pattern.`; 28 | window.showInformationMessage(infoMsg); 29 | } else if (result === NO) { 30 | _removePythonKeywords(config); 31 | const infoMsg = `**pythonKeywords** setting has been removed.`; 32 | window.showInformationMessage(infoMsg); 33 | } 34 | } 35 | } 36 | 37 | function _promptShouldUpdate() { 38 | const promptMsg = 39 | `[RobotFramework] '**pythonKeywords**' setting is deprecated in ` + 40 | `favor of '**includePaths**'. Do you want to migrate to include pattern '\\*\\*/*.py'?`; 41 | 42 | return window.showInformationMessage(promptMsg, YES, NO); 43 | } 44 | 45 | function _replacePythonKeywords( 46 | config: WorkspaceConfiguration, 47 | hasLocal: boolean, 48 | hasGlobal: boolean 49 | ) { 50 | _removePythonKeywords(config); 51 | 52 | const includeConfig = config.inspect(INCLUDE_CONFIG_KEY); 53 | 54 | if (hasLocal) { 55 | const includePaths = _getIncludePatterns(includeConfig.workspaceValue); 56 | 57 | config.update(INCLUDE_CONFIG_KEY, includePaths, false); 58 | } 59 | if (hasGlobal) { 60 | const includePaths = _getIncludePatterns(includeConfig.globalValue); 61 | 62 | config.update(INCLUDE_CONFIG_KEY, includePaths, true); 63 | } 64 | } 65 | 66 | function _getIncludePatterns(includePaths = []) { 67 | const patterns = ["**/*.py"]; 68 | 69 | if (includePaths.length === 0) { 70 | patterns.push("**/*.robot"); 71 | patterns.push("**/*.resource"); 72 | } 73 | 74 | return includePaths.concat(patterns); 75 | } 76 | 77 | function _removePythonKeywords(config: WorkspaceConfiguration) { 78 | config.update(PY_CONFIG_KEY, undefined, true); 79 | config.update(PY_CONFIG_KEY, undefined, false); 80 | } 81 | -------------------------------------------------------------------------------- /client/src/rf-server-client.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { workspace, Disposable, ExtensionContext } from "vscode"; 3 | import { 4 | LanguageClient, 5 | LanguageClientOptions, 6 | ServerOptions, 7 | TransportKind, 8 | RequestType, 9 | } from "vscode-languageclient/node"; 10 | import { Config, CONFIG_BLOCK_NAME } from "./utils/config"; 11 | 12 | const SERVER_PATH = path.join("server", "out", "server.js"); 13 | 14 | export interface BuildFromFilesParam { 15 | files: string[]; 16 | } 17 | 18 | const BuildFromFilesRequestType = new RequestType< 19 | BuildFromFilesParam, 20 | void, 21 | void 22 | >("buildFromFiles"); 23 | 24 | /** 25 | * Client to connect to the language server 26 | */ 27 | export default class RFServerClient implements Disposable { 28 | private _client: LanguageClient; 29 | private _context: ExtensionContext; 30 | 31 | constructor(context: ExtensionContext) { 32 | this._context = context; 33 | } 34 | 35 | public dispose() { 36 | this.stop(); 37 | } 38 | 39 | public start() { 40 | if (this._client) { 41 | throw new Error("Client already running"); 42 | } 43 | 44 | this._client = this._createClient(); 45 | return this._client.start(); 46 | } 47 | 48 | public restart() { 49 | return this.stop().then(() => this.start()); 50 | } 51 | 52 | public stop() { 53 | if (this._client) { 54 | const stopPromise = this._client.stop(); 55 | this._client = null; 56 | 57 | return stopPromise; 58 | } else { 59 | return Promise.resolve(); 60 | } 61 | } 62 | 63 | public sendBuildFilesRequest(files) { 64 | this._client.sendRequest(BuildFromFilesRequestType, { 65 | files, 66 | }); 67 | } 68 | 69 | private _createClient(): LanguageClient { 70 | // The server is implemented in node 71 | const serverModule = this._context.asAbsolutePath(SERVER_PATH); 72 | // The debug options for the server 73 | const debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] }; 74 | 75 | const include = Config.getInclude(); 76 | 77 | // If the extension is launched in debug mode then the debug server options are used 78 | // Otherwise the run options are used 79 | const serverOptions: ServerOptions = { 80 | run: { module: serverModule, transport: TransportKind.ipc }, 81 | debug: { 82 | module: serverModule, 83 | transport: TransportKind.ipc, 84 | options: debugOptions, 85 | }, 86 | }; 87 | 88 | // Options to control the language client 89 | const clientOptions: LanguageClientOptions = { 90 | // Register the server for robot and resource documents 91 | documentSelector: ["robot", "resource"], 92 | synchronize: { 93 | // Synchronize the setting section to the server 94 | configurationSection: CONFIG_BLOCK_NAME, 95 | fileEvents: workspace.createFileSystemWatcher(include), 96 | }, 97 | }; 98 | 99 | return new LanguageClient( 100 | "rfLanguageServer", 101 | "Robot Framework Intellisense Server", 102 | serverOptions, 103 | clientOptions, 104 | true 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /client/src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from "vscode"; 2 | 3 | export const CONFIG_BLOCK_NAME = "rfLanguageServer"; 4 | 5 | export class Config { 6 | public static settings = workspace.getConfiguration(CONFIG_BLOCK_NAME); 7 | 8 | public static reloadConfig() { 9 | Config.settings = workspace.getConfiguration(CONFIG_BLOCK_NAME); 10 | } 11 | 12 | public static getSettings() { 13 | Config.reloadConfig(); 14 | 15 | return Config.settings; 16 | } 17 | 18 | /** 19 | * Returns configured include patterns or default pattern 20 | */ 21 | public static getInclude() { 22 | Config.reloadConfig(); 23 | 24 | const includePatterns = Config.settings 25 | ? Config.settings.get("includePaths") 26 | : []; 27 | 28 | return _createGlob( 29 | includePatterns.length > 0 30 | ? includePatterns 31 | : ["**/*.robot", "**/*.resource"] 32 | ); 33 | } 34 | 35 | public static getExclude() { 36 | Config.reloadConfig(); 37 | 38 | const exlcudePatterns = Config.settings 39 | ? Config.settings.get("excludePaths") 40 | : []; 41 | 42 | return _createGlob(exlcudePatterns); 43 | } 44 | } 45 | 46 | function _createGlob(patterns: string[]) { 47 | switch (patterns.length) { 48 | case 0: 49 | return ""; 50 | case 1: 51 | return patterns[0]; 52 | default: 53 | return `{${patterns.join(",")}}`; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ "es2016" ], 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "noUnusedLocals": true, 7 | "outDir": "out", 8 | "rootDir": "src", 9 | "sourceMap": true, 10 | "target": "es6" 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "server" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /design/ast-hierarchy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomi/vscode-rf-language-server/ee8cbfb29146a4002eb5054b55889b6cbd8289a6/design/ast-hierarchy.png -------------------------------------------------------------------------------- /design/data-models.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomi/vscode-rf-language-server/ee8cbfb29146a4002eb5054b55889b6cbd8289a6/design/data-models.png -------------------------------------------------------------------------------- /design/extension-behaviour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomi/vscode-rf-language-server/ee8cbfb29146a4002eb5054b55889b6cbd8289a6/design/extension-behaviour.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomi/vscode-rf-language-server/ee8cbfb29146a4002eb5054b55889b6cbd8289a6/icon.png -------------------------------------------------------------------------------- /library-docs/.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /library-docs/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "parser":"typescript", 5 | "printWidth": 80, 6 | "semi": true, 7 | "singleQuote": false, 8 | "tabWidth": 2, 9 | "trailingComma": "es5", 10 | "useTabs": false 11 | } -------------------------------------------------------------------------------- /library-docs/fetch-stds.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | urls=( 4 | http://robotframework.org/Selenium2Library/Selenium2Library.html 5 | http://robotframework.org/Selenium2Library/Selenium2Library-1.8.0.html 6 | http://robotframework.org/SeleniumLibrary/SeleniumLibrary.html 7 | ) 8 | 9 | for url in "${urls[@]}" 10 | do 11 | echo "$url" 12 | npx ts-node src/fetch-library-documentation $url 13 | done 14 | 15 | versions=(3.0.4 2.9.2 2.8.7 2.7.7) 16 | libs=(BuiltIn Collections DateTime Dialogs OperatingSystem Process Screenshot String Telnet XML) 17 | 18 | for v in "${versions[@]}" 19 | do 20 | for lib in "${libs[@]}" 21 | do 22 | echo "http://robotframework.org/robotframework/$v/libraries/$lib.html" 23 | npx ts-node src/fetch-library-documentation http://robotframework.org/robotframework/$v/libraries/$lib.html 24 | done 25 | done 26 | -------------------------------------------------------------------------------- /library-docs/library-docs/Dialogs-2.7.7.json: -------------------------------------------------------------------------------- 1 | {"name":"Dialogs","version":"2.7.7","keywords":[{"name":"Execute Manual Step","args":"message, default_error=","doc":"Pauses test execution until user sets the keyword status.\n\nUser can select 'PASS' or 'FAIL', and in the latter case an additional dialog is opened for defining the error message.\n\nmessage is the instruction shown in the initial dialog and default\\_error is the default value shown in the possible error message dialog."},{"name":"Get Selection From User","args":"message, *values","doc":"Pauses test execution and asks user to select a value.\n\nmessage is the instruction shown in the dialog and values are the options given to the user. Selecting 'Cancel' fails the keyword."},{"name":"Get Value From User","args":"message, default_value=","doc":"Pauses test execution and asks user to input a value.\n\nmessage is the instruction shown in the dialog and default\\_value is the possible default value shown in the input field. Selecting 'Cancel' fails the keyword."},{"name":"Pause Execution","args":"message=Test execution paused. Press OK to continue.","doc":"Pauses test execution until user clicks Ok button.\n\nmessage is the message shown in the dialog."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Dialogs-2.8.7.json: -------------------------------------------------------------------------------- 1 | {"name":"Dialogs","version":"2.8.7","keywords":[{"name":"Execute Manual Step","args":"message, default_error=","doc":"Pauses test execution until user sets the keyword status.\n\nUser can press either PASS or FAIL button. In the latter case execution fails and an additional dialog is opened for defining the error message.\n\nmessage is the instruction shown in the initial dialog and default\\_error is the default value shown in the possible error message dialog."},{"name":"Get Selection From User","args":"message, *values","doc":"Pauses test execution and asks user to select a value.\n\nThe selected value is returned. Pressing Cancel fails the keyword.\n\nmessage is the instruction shown in the dialog and values are the options given to the user.\n\nExample:\n\n```\n${username} = Get Selection From User Select user name user1 user2 admin\n```"},{"name":"Get Value From User","args":"message, default_value=, hidden=False","doc":"Pauses test execution and asks user to input a value.\n\nValue typed by the user, or the possible default value, is returned. Returning an empty value is fine, but pressing Cancel fails the keyword.\n\nmessage is the instruction shown in the dialog and default\\_value is the possible default value shown in the input field.\n\nIf hidden is given any true value, such as any non-empty string, the value typed by the user is hidden. This is a new feature in Robot Framework 2.8.4.\n\nExample:\n\n```\n${username} = Get Value From User Input user name default\n${password} = Get Value From User Input password hidden=yes\n```"},{"name":"Pause Execution","args":"message=Test execution paused. Press OK to continue.","doc":"Pauses test execution until user clicks Ok button.\n\nmessage is the message shown in the dialog."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Dialogs-2.9.2.json: -------------------------------------------------------------------------------- 1 | {"name":"Dialogs","version":"2.9.2","keywords":[{"name":"Execute Manual Step","args":["message","default_error="],"doc":"Pauses test execution until user sets the keyword status.\n\nUser can press either `PASS` or `FAIL` button. In the latter case execution fails and an additional dialog is opened for defining the error message.\n\n`message` is the instruction shown in the initial dialog and `default_error` is the default value shown in the possible error message dialog."},{"name":"Get Selection From User","args":["message","*values"],"doc":"Pauses test execution and asks user to select a value.\n\nThe selected value is returned. Pressing `Cancel` fails the keyword.\n\n`message` is the instruction shown in the dialog and `values` are the options given to the user.\n\nExample:\n\n```\n${username} = Get Selection From User Select user name user1 user2 admin\n```"},{"name":"Get Value From User","args":["message","default_value=","hidden=False"],"doc":"Pauses test execution and asks user to input a value.\n\nValue typed by the user, or the possible default value, is returned. Returning an empty value is fine, but pressing `Cancel` fails the keyword.\n\n`message` is the instruction shown in the dialog and `default_value` is the possible default value shown in the input field.\n\nIf `hidden` is given a true value, the value typed by the user is hidden. `hidden` is considered true if it is a non-empty string not equal to `false` or `no`, case-insensitively. If it is not a string, its truth value is got directly using same [rules as in Python](http://docs.python.org/2/library/stdtypes.html#truth-value-testing).\n\nExample:\n\n```\n${username} = Get Value From User Input user name default\n${password} = Get Value From User Input password hidden=yes\n```\n\nPossibility to hide the typed in value is new in Robot Framework 2.8.4. Considering strings `false` and `no` to be false is new in 2.9."},{"name":"Pause Execution","args":["message=Test execution paused. Press OK to continue."],"doc":"Pauses test execution until user clicks `Ok` button.\n\n`message` is the message shown in the dialog."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Dialogs-3.0.4.json: -------------------------------------------------------------------------------- 1 | {"name":"Dialogs","version":"3.0.4","keywords":[{"name":"Execute Manual Step","args":["message","default_error="],"doc":"Pauses test execution until user sets the keyword status.\n\nUser can press either `PASS` or `FAIL` button. In the latter case execution fails and an additional dialog is opened for defining the error message.\n\n`message` is the instruction shown in the initial dialog and `default_error` is the default value shown in the possible error message dialog."},{"name":"Get Selection From User","args":["message","*values"],"doc":"Pauses test execution and asks user to select a value.\n\nThe selected value is returned. Pressing `Cancel` fails the keyword.\n\n`message` is the instruction shown in the dialog and `values` are the options given to the user.\n\nExample:\n\n```\n${username} = Get Selection From User Select user name user1 user2 admin\n```"},{"name":"Get Value From User","args":["message","default_value=","hidden=False"],"doc":"Pauses test execution and asks user to input a value.\n\nValue typed by the user, or the possible default value, is returned. Returning an empty value is fine, but pressing `Cancel` fails the keyword.\n\n`message` is the instruction shown in the dialog and `default_value` is the possible default value shown in the input field.\n\nIf `hidden` is given a true value, the value typed by the user is hidden. `hidden` is considered true if it is a non-empty string not equal to `false`, `none` or `no`, case-insensitively. If it is not a string, its truth value is got directly using same [rules as in Python](http://docs.python.org/2/library/stdtypes.html#truth-value-testing).\n\nExample:\n\n```\n${username} = Get Value From User Input user name default\n${password} = Get Value From User Input password hidden=yes\n```\n\nPossibility to hide the typed in value is new in Robot Framework 2.8.4. Considering strings `false` and `no` to be false is new in RF 2.9 and considering string `none` false is new in RF 3.0.3."},{"name":"Pause Execution","args":["message=Test execution paused. Press OK to continue."],"doc":"Pauses test execution until user clicks `Ok` button.\n\n`message` is the message shown in the dialog."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Dialogs-3.1.1.json: -------------------------------------------------------------------------------- 1 | {"name": "Dialogs", "version": "3.1.1", "keywords": [{"name": "Execute Manual Step", "args": ["message", "default_error="], "doc": "Pauses test execution until user sets the keyword status.\n\nUser can press either ``PASS`` or ``FAIL`` button. In the latter case execution\nfails and an additional dialog is opened for defining the error message.\n\n``message`` is the instruction shown in the initial dialog and\n``default_error`` is the default value shown in the possible error message\ndialog."}, {"name": "Get Selection From User", "args": ["message", "*values"], "doc": "Pauses test execution and asks user to select a value.\n\nThe selected value is returned. Pressing ``Cancel`` fails the keyword.\n\n``message`` is the instruction shown in the dialog and ``values`` are\nthe options given to the user.\n\nExample:\n| ${user} = | Get Selection From User | Select user | user1 | user2 | admin |"}, {"name": "Get Selections From User", "args": ["message", "*values"], "doc": "Pauses test execution and asks user to select multiple values.\n\nThe selected values are returned as a list. Selecting no values is OK\nand in that case the returned list is empty. Pressing ``Cancel`` fails\nthe keyword.\n\n``message`` is the instruction shown in the dialog and ``values`` are\nthe options given to the user.\n\nExample:\n| ${users} = | Get Selections From User | Select users | user1 | user2 | admin |\n\nNew in Robot Framework 3.1."}, {"name": "Get Value From User", "args": ["message", "default_value=", "hidden=False"], "doc": "Pauses test execution and asks user to input a value.\n\nValue typed by the user, or the possible default value, is returned.\nReturning an empty value is fine, but pressing ``Cancel`` fails the keyword.\n\n``message`` is the instruction shown in the dialog and ``default_value`` is\nthe possible default value shown in the input field.\n\nIf ``hidden`` is given a true value, the value typed by the user is hidden.\n``hidden`` is considered true if it is a non-empty string not equal to\n``false``, ``none`` or ``no``, case-insensitively. If it is not a string,\nits truth value is got directly using same\n[http://docs.python.org/library/stdtypes.html#truth|rules as in Python].\n\nExample:\n| ${username} = | Get Value From User | Input user name | default |\n| ${password} = | Get Value From User | Input password | hidden=yes |\n\nConsidering strings ``false`` and ``no`` to be false is new in RF 2.9\nand considering string ``none`` false is new in RF 3.0.3."}, {"name": "Pause Execution", "args": ["message=Test execution paused. Press OK to continue."], "doc": "Pauses test execution until user clicks ``Ok`` button.\n\n``message`` is the message shown in the dialog."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Screenshot-2.7.7.json: -------------------------------------------------------------------------------- 1 | {"name":"Screenshot","version":"2.7.7","keywords":[{"name":"Log Screenshot","args":"basename=screenshot, directory=None, width=100%","doc":"**DEPRECATED** Use [Take Screenshot](#Take Screenshot) or [Take Screenshot Without Embedding](#Take Screenshot Without Embedding) instead."},{"name":"Save Screenshot","args":"basename=screenshot, directory=None","doc":"**DEPRECATED** Use [Take Screenshot](#Take Screenshot) or [Take Screenshot Without Embedding](#Take Screenshot Without Embedding) instead."},{"name":"Save Screenshot To","args":"path","doc":"**DEPRECATED** Use [Take Screenshot](#Take Screenshot) or [Take Screenshot Without Embedding](#Take Screenshot Without Embedding) instead."},{"name":"Set Screenshot Directory","args":"path","doc":"Sets the directory where screenshots are saved.\n\nIt is possible to use / as a path separator in all operating systems. Path to the old directory is returned.\n\nThe directory can also be set in [importing](#Importing)."},{"name":"Take Screenshot","args":"name=screenshot, width=800px","doc":"Takes a screenshot in JPEG format and embeds it into the log file.\n\nName of the file where the screenshot is stored is derived from the given name. If the name ends with extension .jpg or .jpeg, the screenshot will be stored with that exact name. Otherwise a unique name is created by adding an underscore, a running index and an extension to the name.\n\nThe name will be interpreted to be relative to the directory where the log file is written. It is also possible to use absolute paths. Using / as a path separator works in all operating systems.\n\nwidth specifies the size of the screenshot in the log file.\n\nExamples: (LOGDIR is determined automatically by the library)\n\n```\nTake Screenshot # LOGDIR/screenshot_1.jpg (index automatically incremented)\nTake Screenshot mypic # LOGDIR/mypic_1.jpg (index automatically incremented)\nTake Screenshot ${TEMPDIR}/mypic # /tmp/mypic_1.jpg (index automatically incremented)\nTake Screenshot pic.jpg # LOGDIR/pic.jpg (always uses this file)\nTake Screenshot images/login.jpg 80% # Specify both name and width.\nTake Screenshot width=550px # Specify only width.\n```\n\nThe path where the screenshot is saved is returned."},{"name":"Take Screenshot Without Embedding","args":"name=screenshot","doc":"Takes a screenshot and links it from the log file.\n\nThis keyword is otherwise identical to [Take Screenshot](#Take Screenshot) but the saved screenshot is not embedded into the log file. The screenshot is linked so it is nevertheless easily available."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Screenshot-2.8.7.json: -------------------------------------------------------------------------------- 1 | {"name":"Screenshot","version":"2.8.7","keywords":[{"name":"Set Screenshot Directory","args":"path","doc":"Sets the directory where screenshots are saved.\n\nIt is possible to use / as a path separator in all operating systems. Path to the old directory is returned.\n\nThe directory can also be set in [importing](#Importing)."},{"name":"Take Screenshot","args":"name=screenshot, width=800px","doc":"Takes a screenshot in JPEG format and embeds it into the log file.\n\nName of the file where the screenshot is stored is derived from the given name. If the name ends with extension .jpg or .jpeg, the screenshot will be stored with that exact name. Otherwise a unique name is created by adding an underscore, a running index and an extension to the name.\n\nThe name will be interpreted to be relative to the directory where the log file is written. It is also possible to use absolute paths. Using / as a path separator works in all operating systems.\n\nwidth specifies the size of the screenshot in the log file.\n\nExamples: (LOGDIR is determined automatically by the library)\n\n```\nTake Screenshot # LOGDIR/screenshot_1.jpg (index automatically incremented)\nTake Screenshot mypic # LOGDIR/mypic_1.jpg (index automatically incremented)\nTake Screenshot ${TEMPDIR}/mypic # /tmp/mypic_1.jpg (index automatically incremented)\nTake Screenshot pic.jpg # LOGDIR/pic.jpg (always uses this file)\nTake Screenshot images/login.jpg 80% # Specify both name and width.\nTake Screenshot width=550px # Specify only width.\n```\n\nThe path where the screenshot is saved is returned."},{"name":"Take Screenshot Without Embedding","args":"name=screenshot","doc":"Takes a screenshot and links it from the log file.\n\nThis keyword is otherwise identical to [Take Screenshot](#Take%20Screenshot) but the saved screenshot is not embedded into the log file. The screenshot is linked so it is nevertheless easily available."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Screenshot-2.9.2.json: -------------------------------------------------------------------------------- 1 | {"name":"Screenshot","version":"2.9.2","keywords":[{"name":"Set Screenshot Directory","args":["path"],"doc":"Sets the directory where screenshots are saved.\n\nIt is possible to use `/` as a path separator in all operating systems. Path to the old directory is returned.\n\nThe directory can also be set in [importing](#Importing)."},{"name":"Take Screenshot","args":["name=screenshot","width=800px"],"doc":"Takes a screenshot in JPEG format and embeds it into the log file.\n\nName of the file where the screenshot is stored is derived from the given `name`. If the `name` ends with extension `.jpg` or `.jpeg`, the screenshot will be stored with that exact name. Otherwise a unique name is created by adding an underscore, a running index and an extension to the `name`.\n\nThe name will be interpreted to be relative to the directory where the log file is written. It is also possible to use absolute paths. Using `/` as a path separator works in all operating systems.\n\n`width` specifies the size of the screenshot in the log file.\n\nExamples: (LOGDIR is determined automatically by the library)\n\n```\nTake Screenshot # LOGDIR/screenshot_1.jpg (index automatically incremented)\nTake Screenshot mypic # LOGDIR/mypic_1.jpg (index automatically incremented)\nTake Screenshot ${TEMPDIR}/mypic # /tmp/mypic_1.jpg (index automatically incremented)\nTake Screenshot pic.jpg # LOGDIR/pic.jpg (always uses this file)\nTake Screenshot images/login.jpg 80% # Specify both name and width.\nTake Screenshot width=550px # Specify only width.\n```\n\nThe path where the screenshot is saved is returned."},{"name":"Take Screenshot Without Embedding","args":["name=screenshot"],"doc":"Takes a screenshot and links it from the log file.\n\nThis keyword is otherwise identical to [Take Screenshot](#Take%20Screenshot) but the saved screenshot is not embedded into the log file. The screenshot is linked so it is nevertheless easily available."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Screenshot-3.0.4.json: -------------------------------------------------------------------------------- 1 | {"name":"Screenshot","version":"3.0.4","keywords":[{"name":"Set Screenshot Directory","args":["path"],"doc":"Sets the directory where screenshots are saved.\n\nIt is possible to use `/` as a path separator in all operating systems. Path to the old directory is returned.\n\nThe directory can also be set in [importing](#Importing)."},{"name":"Take Screenshot","args":["name=screenshot","width=800px"],"doc":"Takes a screenshot in JPEG format and embeds it into the log file.\n\nName of the file where the screenshot is stored is derived from the given `name`. If the `name` ends with extension `.jpg` or `.jpeg`, the screenshot will be stored with that exact name. Otherwise a unique name is created by adding an underscore, a running index and an extension to the `name`.\n\nThe name will be interpreted to be relative to the directory where the log file is written. It is also possible to use absolute paths. Using `/` as a path separator works in all operating systems.\n\n`width` specifies the size of the screenshot in the log file.\n\nExamples: (LOGDIR is determined automatically by the library)\n\n```\nTake Screenshot # LOGDIR/screenshot_1.jpg (index automatically incremented)\nTake Screenshot mypic # LOGDIR/mypic_1.jpg (index automatically incremented)\nTake Screenshot ${TEMPDIR}/mypic # /tmp/mypic_1.jpg (index automatically incremented)\nTake Screenshot pic.jpg # LOGDIR/pic.jpg (always uses this file)\nTake Screenshot images/login.jpg 80% # Specify both name and width.\nTake Screenshot width=550px # Specify only width.\n```\n\nThe path where the screenshot is saved is returned."},{"name":"Take Screenshot Without Embedding","args":["name=screenshot"],"doc":"Takes a screenshot and links it from the log file.\n\nThis keyword is otherwise identical to [Take Screenshot](#Take%20Screenshot) but the saved screenshot is not embedded into the log file. The screenshot is linked so it is nevertheless easily available."}]} -------------------------------------------------------------------------------- /library-docs/library-docs/Screenshot-3.1.1.json: -------------------------------------------------------------------------------- 1 | {"name": "Screenshot", "version": "3.1.1", "keywords": [{"name": "Set Screenshot Directory", "args": ["path"], "doc": "Sets the directory where screenshots are saved.\n\nIt is possible to use ``/`` as a path separator in all operating\nsystems. Path to the old directory is returned.\n\nThe directory can also be set in `importing`."}, {"name": "Take Screenshot", "args": ["name=screenshot", "width=800px"], "doc": "Takes a screenshot in JPEG format and embeds it into the log file.\n\nName of the file where the screenshot is stored is derived from the\ngiven ``name``. If the ``name`` ends with extension ``.jpg`` or\n``.jpeg``, the screenshot will be stored with that exact name.\nOtherwise a unique name is created by adding an underscore, a running\nindex and an extension to the ``name``.\n\nThe name will be interpreted to be relative to the directory where\nthe log file is written. It is also possible to use absolute paths.\nUsing ``/`` as a path separator works in all operating systems.\n\n``width`` specifies the size of the screenshot in the log file.\n\nExamples: (LOGDIR is determined automatically by the library)\n| Take Screenshot | | | # LOGDIR/screenshot_1.jpg (index automatically incremented) |\n| Take Screenshot | mypic | | # LOGDIR/mypic_1.jpg (index automatically incremented) |\n| Take Screenshot | ${TEMPDIR}/mypic | | # /tmp/mypic_1.jpg (index automatically incremented) |\n| Take Screenshot | pic.jpg | | # LOGDIR/pic.jpg (always uses this file) |\n| Take Screenshot | images/login.jpg | 80% | # Specify both name and width. |\n| Take Screenshot | width=550px | | # Specify only width. |\n\nThe path where the screenshot is saved is returned."}, {"name": "Take Screenshot Without Embedding", "args": ["name=screenshot"], "doc": "Takes a screenshot and links it from the log file.\n\nThis keyword is otherwise identical to `Take Screenshot` but the saved\nscreenshot is not embedded into the log file. The screenshot is linked\nso it is nevertheless easily available."}]} -------------------------------------------------------------------------------- /library-docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rf-intellisense", 3 | "version": "2.8.0", 4 | "description": "Robot Framework Intellisense", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Tomi Turtiainen", 8 | "email": "tomi.turtiainen@gmail.com" 9 | }, 10 | "engines": { 11 | "node": "16" 12 | }, 13 | "dependencies": { 14 | "cheerio": "1.0.0-rc.12", 15 | "node-fetch": "2.7.0", 16 | "ts-node": "10.9.1", 17 | "turndown": "7.1.2", 18 | "typescript": "5.2.2" 19 | }, 20 | "devDependencies": { 21 | "@types/cheerio": "0.22.32", 22 | "@types/node": "16.18.55", 23 | "@types/node-fetch": "2.6.6", 24 | "prettier": "3.0.3", 25 | "tslint": "6.1.3" 26 | }, 27 | "main": "index.js", 28 | "scripts": { 29 | "fetch": "ts-node src/fetch-library-documentation", 30 | "lint": "npm run tslint && npm run prettier", 31 | "prettier": "prettier -l src/**", 32 | "tslint": "tslint --project . --config ../tslint.json" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/tomi/vscode-rf-language-server.git" 37 | }, 38 | "keywords": [ 39 | "RF", 40 | "RobotFramework", 41 | "Intellisense" 42 | ], 43 | "bugs": { 44 | "url": "https://github.com/tomi/vscode-rf-language-server/issues" 45 | }, 46 | "homepage": "https://github.com/tomi/vscode-rf-language-server#readme" 47 | } 48 | -------------------------------------------------------------------------------- /library-docs/src/fetch-library-documentation.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { writeFileSync } from "fs"; 3 | import fetch from "node-fetch"; 4 | import { load } from "cheerio"; 5 | import * as TurndownService from "turndown"; 6 | 7 | const LIBS_DIR = join(__dirname, "../library-docs"); 8 | 9 | const turndownService = new TurndownService(); 10 | turndownService.addRule("examplesTable", { 11 | filter: ["table"], 12 | replacement: (content, node) => _tableNodeToMarkdown(node), 13 | }); 14 | 15 | async function fetchLibFromUrl(url: string) { 16 | try { 17 | const html = await _fetchHtml(url); 18 | 19 | const library = _parseLibrary(html); 20 | 21 | const path = _writeLibraryToFile(library); 22 | 23 | console.log(`Library written to ${path}`); 24 | } catch (error) { 25 | console.error(`Unable to fetch library from URL ${url}`); 26 | console.error(error); 27 | process.exit(2); 28 | } 29 | } 30 | 31 | async function _fetchHtml(url: string) { 32 | const response = await fetch(url); 33 | if (!response.ok) { 34 | console.error(`Unable to fetch library from URL ${url}`); 35 | console.error(`Invalid response ${response.status} ${response.statusText}`); 36 | process.exit(2); 37 | } 38 | 39 | return await response.text(); 40 | } 41 | 42 | function _parseLibrary(html: string) { 43 | const $ = load(html); 44 | const libraryJsonString = $( 45 | "script[type='text/javascript']:contains('libdoc =')" 46 | ) 47 | .html() 48 | .slice(9, -2) 49 | .replace(/\\x3c/g, "<"); 50 | 51 | const libraryData = JSON.parse(libraryJsonString); 52 | 53 | const keywords = _parseKeywords(libraryData.keywords); 54 | 55 | return { 56 | name: libraryData.name, 57 | version: libraryData.version, 58 | keywords, 59 | }; 60 | } 61 | 62 | function _parseKeywords(keywords) { 63 | return keywords.map(kw => ({ 64 | name: kw.name, 65 | args: kw.args, 66 | doc: _parseDocumentation(kw.doc), 67 | })); 68 | } 69 | 70 | function _parseDocumentation(documentation: string) { 71 | const parsed = turndownService.turndown(documentation); 72 | 73 | return parsed; 74 | } 75 | 76 | function _tableNodeToMarkdown(node) { 77 | const rowContents = []; 78 | const rows = node.rows; 79 | 80 | for (let i = 0; i < rows.length; i++) { 81 | const row = rows.item(i); 82 | 83 | const cells = row.cells; 84 | const cellContents = []; 85 | for (let j = 0; j < cells.length; j++) { 86 | const cell = cells.item(j); 87 | cellContents.push(cell.textContent); 88 | } 89 | 90 | rowContents.push(cellContents.join(" ")); 91 | } 92 | 93 | return ["```", ...rowContents, "```"].join("\n"); 94 | } 95 | 96 | function _writeLibraryToFile(library) { 97 | const libraryFilename = `${library.name}-${library.version}.json`; 98 | const libraryFilePath = join(LIBS_DIR, libraryFilename); 99 | 100 | writeFileSync(libraryFilePath, JSON.stringify(library)); 101 | 102 | return libraryFilePath; 103 | } 104 | 105 | const libUrl = process.argv[process.argv.length - 1]; 106 | if (!libUrl.startsWith("http")) { 107 | console.log(`Invalid URL given ${libUrl}`); 108 | process.exit(1); 109 | } 110 | 111 | fetchLibFromUrl(libUrl); 112 | -------------------------------------------------------------------------------- /library-docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib" : [ "es2016" ], 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "noUnusedLocals": true, 7 | "sourceMap": true, 8 | "target": "es6" 9 | }, 10 | "exclude": [ 11 | "node_modules" 12 | ] 13 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rf-intellisense", 3 | "version": "2.8.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "rf-intellisense", 9 | "version": "2.8.0", 10 | "hasInstallScript": true, 11 | "license": "MIT", 12 | "devDependencies": { 13 | "typescript": "5.2.2" 14 | }, 15 | "engines": { 16 | "node": "^16.18.55", 17 | "vscode": "^1.70.0" 18 | } 19 | }, 20 | "node_modules/typescript": { 21 | "version": "5.2.2", 22 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", 23 | "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", 24 | "dev": true, 25 | "bin": { 26 | "tsc": "bin/tsc", 27 | "tsserver": "bin/tsserver" 28 | }, 29 | "engines": { 30 | "node": ">=14.17" 31 | } 32 | } 33 | }, 34 | "dependencies": { 35 | "typescript": { 36 | "version": "5.2.2", 37 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", 38 | "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", 39 | "dev": true 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /robot-language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#" 4 | }, 5 | "brackets": [ 6 | ["{", "}"], 7 | ["[", "]"], 8 | ["(", ")"] 9 | ], 10 | "autoClosingPairs": [ 11 | { "open": "{", "close": "}" }, 12 | { "open": "[", "close": "]" }, 13 | { "open": "(", "close": ")" }, 14 | { "open": "\"", "close": "\"" } 15 | ], 16 | "surroundingPairs": [ 17 | ["{", "}"], 18 | ["[", "]"], 19 | ["(", ")"], 20 | ["<", ">"], 21 | ["\"", "\""], 22 | ["'", "'"], 23 | ["`", "`"] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | -------------------------------------------------------------------------------- /server/.nvmrc: -------------------------------------------------------------------------------- 1 | 8 2 | -------------------------------------------------------------------------------- /server/.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /server/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "parser":"typescript", 5 | "printWidth": 80, 6 | "semi": true, 7 | "singleQuote": false, 8 | "tabWidth": 2, 9 | "trailingComma": "es5", 10 | "useTabs": false 11 | } -------------------------------------------------------------------------------- /server/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | // List of configurations. Add new configurations or edit existing ones. 4 | "configurations": [ 5 | { 6 | "type": "node", 7 | "request": "launch", 8 | "name": "Run Unit Tests", 9 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 10 | "sourceMaps": true, 11 | "cwd": "${workspaceRoot}", 12 | "preLaunchTask": "build", 13 | "outFiles": [ 14 | "${workspaceRoot}/out/**/*.js", 15 | "!${workspaceRoot}/node_modules/**" 16 | ], 17 | "args": [ 18 | "${workspaceRoot}/out/**/*.test.js" 19 | ] 20 | }, 21 | { 22 | "name": "Attach", 23 | "type": "node", 24 | "request": "attach", 25 | "port": 6009, 26 | "sourceMaps": true, 27 | "outFiles": [ 28 | "${workspaceRoot}/../client/server/**/*.js", 29 | "!${workspaceRoot}/../client/server/**/test/**", 30 | "!${workspaceRoot}/../client/server/**/node_modules/**" 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /server/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true, 4 | "**/bower_components": true, 5 | "**/*.js.map": true, 6 | "**/*.js": true 7 | } 8 | } -------------------------------------------------------------------------------- /server/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": [ 10 | "$tsc-watch" 11 | ], 12 | "isBackground": true 13 | }, 14 | { 15 | "type": "npm", 16 | "script": "lint", 17 | "problemMatcher": [ 18 | "$tslint5" 19 | ] 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Robot Framework Intellisense - Language Server 2 | 3 | This is a language server implementation 4 | -------------------------------------------------------------------------------- /server/TODO.md: -------------------------------------------------------------------------------- 1 | # Plugin 2 | * Follow imports when finding definition 3 | * Rename 4 | * Better completion provider 5 | * Hover provider 6 | * Support for built-in libraries 7 | * Show all matches with Go To definition when there are multiple keywords with same name 8 | 9 | # Parser 10 | * Accessing list and dictionary variables 11 | * For loop 12 | * Make loc optional 13 | * Handle escaping of special characters, e.g. do not parse as variable expression if it is escaped 14 | * Move Position to more sensible location 15 | * Support for missing settings table settings: 16 | * Metadata 17 | * Force tags 18 | * Default tags 19 | * Test template 20 | * Test timeout 21 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rf-intellisense", 3 | "version": "2.8.0", 4 | "description": "Robot Framework Intellisense", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Tomi Turtiainen", 8 | "email": "tomi.turtiainen@gmail.com" 9 | }, 10 | "engines": { 11 | "node": "18" 12 | }, 13 | "dependencies": { 14 | "lodash": "4.17.21", 15 | "minimatch": "9.0.3", 16 | "node-ternary-search-trie": "https://github.com/tomi/node-ternary-search-trie.git", 17 | "vscode-languageserver": "9.0.1", 18 | "vscode-uri": "3.0.7" 19 | }, 20 | "devDependencies": { 21 | "@types/chai": "4.3.6", 22 | "@types/lodash": "4.14.199", 23 | "@types/minimatch": "5.1.2", 24 | "@types/mocha": "10.0.2", 25 | "@types/node": "18.17.15", 26 | "chai": "4.3.10", 27 | "mocha": "10.2.0", 28 | "prettier": "3.0.3", 29 | "ts-node": "10.9.1", 30 | "tslint": "6.1.3", 31 | "typescript": "5.2.2" 32 | }, 33 | "main": "index.js", 34 | "scripts": { 35 | "clean": "rm -rf ./out", 36 | "copy-libs": "mkdir -p ../client/server", 37 | "lint": "npm run tslint && npm run prettier", 38 | "prettier": "prettier -l src/**", 39 | "test": "mocha --require ts-node/register src/**/*.test.ts", 40 | "tslint": "tslint --project . --config ../tslint.json" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/tomi/vscode-rf-language-server.git" 45 | }, 46 | "keywords": [ 47 | "RF", 48 | "RobotFramework", 49 | "Intellisense" 50 | ], 51 | "bugs": { 52 | "url": "https://github.com/tomi/vscode-rf-language-server/issues" 53 | }, 54 | "homepage": "https://github.com/tomi/vscode-rf-language-server#readme" 55 | } 56 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import Workspace from "./workspace/workspace"; 3 | import { ConsoleLogger as logger } from "../logger"; 4 | import { Location, isInRange } from "../utils/position"; 5 | import { findLocationInfo } from "./node-locator"; 6 | import { RobotFile } from "./workspace/robot-file"; 7 | 8 | import { 9 | getSuiteCompletion, 10 | getSettingsTableCompletions, 11 | getVariableTableCompletions, 12 | getKeywordTableCompletions, 13 | getTestCaseTableCompletions, 14 | } from "./completion-provider/completion-providers"; 15 | 16 | export function findCompletionItems(location: Location, workspace: Workspace) { 17 | const position = location.position; 18 | 19 | const file = workspace.getFile(location.filePath) as RobotFile; 20 | if (!file) { 21 | logger.info(`Definition not found. File '${location.filePath}' not parsed`); 22 | return []; 23 | } 24 | const ast = file.ast; 25 | 26 | const locationInfo = findLocationInfo(location, file.tables); 27 | if (!locationInfo) { 28 | logger.info( 29 | `Location info not available. Location '${location.position}' not available` 30 | ); 31 | return []; 32 | } 33 | 34 | const suiteCompletions = getSuiteCompletion(location, locationInfo, ast); 35 | if (!_.isEmpty(suiteCompletions)) { 36 | return suiteCompletions; 37 | } 38 | 39 | if (isInRange(position, ast.settingsTable)) { 40 | return getSettingsTableCompletions(location, locationInfo, ast, workspace); 41 | } else if (isInRange(position, ast.variablesTable)) { 42 | return getVariableTableCompletions(location, locationInfo, ast, workspace); 43 | } else if (isInRange(position, ast.keywordsTable)) { 44 | return getKeywordTableCompletions(location, locationInfo, ast, workspace); 45 | } else if (isInRange(position, ast.testCasesTable)) { 46 | return getTestCaseTableCompletions(location, locationInfo, ast, workspace); 47 | } 48 | 49 | return []; 50 | } 51 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/completion-providers.ts: -------------------------------------------------------------------------------- 1 | export { getCompletions as getSuiteCompletion } from "./suite-completions"; 2 | 3 | export { getCompletions as getSettingsTableCompletions } from "./settings-table-completions"; 4 | 5 | export { getCompletions as getVariableTableCompletions } from "./variable-table-completions"; 6 | 7 | export { getCompletions as getKeywordTableCompletions } from "./keyword-table-completions"; 8 | 9 | export { getCompletions as getTestCaseTableCompletions } from "./testcase-table-completions"; 10 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/functions-table-completions.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import Workspace from "../workspace/workspace"; 3 | import { Node, TestSuite, FunctionDeclaration } from "../../parser/models"; 4 | import * as typeGuards from "../type-guards"; 5 | import { findLocalVariables, LocationInfo } from "../node-locator"; 6 | import { CompletionItem } from "vscode-languageserver"; 7 | import { traverse, VisitorOption } from "../../traverse/traverse"; 8 | import { Location, isOnLine } from "../../utils/position"; 9 | import { 10 | getSyntaxCompletions, 11 | getKeywordCompletions, 12 | getVariableCompletions, 13 | } from "./completion-helper"; 14 | import { VariableContainer } from "../search-tree"; 15 | 16 | const VARIABLE_CHARS = new Set(["$", "@", "&", "%"]); 17 | 18 | export function getCompletions( 19 | location: Location, 20 | locationInfo: LocationInfo, 21 | fileAst: TestSuite, 22 | workspace: Workspace, 23 | settings: string[] 24 | ): CompletionItem[] { 25 | const { row, cell, textBefore } = locationInfo; 26 | 27 | const cellIndex = row.indexOf(cell); 28 | const line = location.position.line; 29 | 30 | if (cellIndex === 0 || !row.first().isEmpty()) { 31 | return []; 32 | } else if (cellIndex === 1) { 33 | if (_startsSetting(textBefore)) { 34 | // Setting, e.g. [Arguments] 35 | return getSyntaxCompletions(textBefore.substring(1), settings); 36 | } else if (_startsVariable(textBefore)) { 37 | // Variable declaration, e.g. ${var} 38 | return []; 39 | } 40 | // else: It's a call expression 41 | 42 | const functionNode = _findFunction(line, fileAst); 43 | const localVariables = functionNode 44 | ? findLocalVariables(functionNode, line) 45 | : VariableContainer.Empty; 46 | return getKeywordCompletions(textBefore, workspace, localVariables); 47 | } else { 48 | const functionNode = _findFunction(line, fileAst); 49 | const nodeOnLine = _findNodeOnLine(line, functionNode); 50 | if (!nodeOnLine) { 51 | return []; 52 | } 53 | 54 | const noCompletions = [ 55 | typeGuards.isDocumentation, 56 | typeGuards.isTags, 57 | typeGuards.isArguments, 58 | typeGuards.isTimeout, 59 | ]; 60 | if (_.some(noCompletions, f => f(nodeOnLine))) { 61 | return []; 62 | } 63 | 64 | const localVariables = findLocalVariables(functionNode, line); 65 | const keywordCompletions = [ 66 | typeGuards.isSetup, 67 | typeGuards.isTeardown, 68 | typeGuards.isTemplate, 69 | ]; 70 | if (cellIndex === 2 && _.some(keywordCompletions, f => f(nodeOnLine))) { 71 | return getKeywordCompletions(textBefore, workspace, localVariables); 72 | } else if ( 73 | typeGuards.isStep(nodeOnLine) && 74 | typeGuards.isVariableDeclaration(nodeOnLine.body) 75 | ) { 76 | return getKeywordCompletions(textBefore, workspace, localVariables); 77 | } 78 | 79 | return getVariableCompletions( 80 | textBefore, 81 | workspace.variables, 82 | localVariables 83 | ); 84 | } 85 | } 86 | 87 | function _startsSetting(text: string) { 88 | return text.startsWith("["); 89 | } 90 | 91 | function _startsVariable(text: string) { 92 | return VARIABLE_CHARS.has(text.charAt(0)); 93 | } 94 | 95 | function _findFunction(line: number, ast: TestSuite) { 96 | const isNodeOnLine = (node: Node) => isOnLine(line, node); 97 | 98 | if (isNodeOnLine(ast.keywordsTable)) { 99 | return ast.keywordsTable.keywords.find(isNodeOnLine); 100 | } else if (isNodeOnLine(ast.testCasesTable)) { 101 | return ast.testCasesTable.testCases.find(isNodeOnLine); 102 | } else { 103 | return null; 104 | } 105 | } 106 | 107 | function _findNodeOnLine(line: number, functionNode: FunctionDeclaration) { 108 | if (!functionNode) { 109 | return null; 110 | } 111 | let foundNode: Node; 112 | 113 | const isNodeOnLine = (node: Node) => isOnLine(line, node); 114 | 115 | traverse(functionNode, { 116 | enter: (node: Node, parent: Node) => { 117 | if (foundNode) { 118 | return VisitorOption.Break; 119 | } 120 | 121 | if (parent === functionNode && isNodeOnLine(node)) { 122 | foundNode = node; 123 | return VisitorOption.Break; 124 | } 125 | 126 | return VisitorOption.Continue; 127 | }, 128 | }); 129 | 130 | return foundNode; 131 | } 132 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/keyword-table-completions.ts: -------------------------------------------------------------------------------- 1 | import Workspace from "../workspace/workspace"; 2 | import { TestSuite } from "../../parser/models"; 3 | import { CompletionItem } from "vscode-languageserver"; 4 | import { Location } from "../../utils/position"; 5 | 6 | import * as functionCompletions from "./functions-table-completions"; 7 | import { LocationInfo } from "../node-locator"; 8 | 9 | const SETTINGS = [ 10 | "Documentation", 11 | "Tags", 12 | "Arguments", 13 | "Return", 14 | "Teardown", 15 | "Timeout", 16 | ]; 17 | 18 | export function getCompletions( 19 | location: Location, 20 | locationInfo: LocationInfo, 21 | fileAst: TestSuite, 22 | workspace: Workspace 23 | ): CompletionItem[] { 24 | return functionCompletions.getCompletions( 25 | location, 26 | locationInfo, 27 | fileAst, 28 | workspace, 29 | SETTINGS 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/settings-table-completions.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as typeGuards from "../type-guards"; 3 | import Workspace from "../workspace/workspace"; 4 | import { Node, TestSuite, SuiteSetting } from "../../parser/models"; 5 | import { CompletionItem } from "vscode-languageserver"; 6 | import { traverse, VisitorOption } from "../../traverse/traverse"; 7 | import { Location, isOnLine } from "../../utils/position"; 8 | import { 9 | getSyntaxCompletions, 10 | getKeywordCompletions, 11 | getVariableCompletions, 12 | } from "./completion-helper"; 13 | import { LocationInfo } from "../node-locator"; 14 | 15 | const KEYWORDS = [ 16 | "Default Tags", 17 | "Documentation", 18 | "Force Tags", 19 | "Library", 20 | "Metadata", 21 | "Resource", 22 | "Suite Setup", 23 | "Suite Teardown", 24 | "Test Setup", 25 | "Test Teardown", 26 | "Test Template", 27 | "Test Timeout", 28 | ]; 29 | 30 | export function getCompletions( 31 | location: Location, 32 | locationInfo: LocationInfo, 33 | fileAst: TestSuite, 34 | workspace: Workspace 35 | ): CompletionItem[] { 36 | const { row, cell, textBefore } = locationInfo; 37 | 38 | const cellIndex = row.indexOf(cell); 39 | const isFirstCell = cellIndex === 0; 40 | if (isFirstCell) { 41 | return getSyntaxCompletions(textBefore, KEYWORDS); 42 | } 43 | 44 | const nodeOnLine = _findNodeOnLine(location.position.line, fileAst); 45 | if (!nodeOnLine) { 46 | return []; 47 | } 48 | 49 | if (typeGuards.isDocumentation(nodeOnLine)) { 50 | return []; 51 | } 52 | if (typeGuards.isSuiteSetting(nodeOnLine)) { 53 | return _getSuiteSettingCompletions( 54 | textBefore, 55 | nodeOnLine, 56 | cellIndex, 57 | workspace 58 | ); 59 | } 60 | 61 | return getVariableCompletions(textBefore, workspace.variables); 62 | } 63 | 64 | function _getSuiteSettingCompletions( 65 | textBefore: string, 66 | suiteSetting: SuiteSetting, 67 | cellIndex: number, 68 | workspace: Workspace 69 | ) { 70 | if (_isSetupOrTeardown(suiteSetting) && cellIndex === 1) { 71 | return getKeywordCompletions(textBefore, workspace); 72 | } else { 73 | return getVariableCompletions(textBefore, workspace.variables); 74 | } 75 | } 76 | 77 | function _isSetupOrTeardown(setting: SuiteSetting) { 78 | const settingName = setting.name.name.toLowerCase(); 79 | 80 | return [ 81 | "suite setup", 82 | "suite teardown", 83 | "test setup", 84 | "test teardown", 85 | ].includes(settingName); 86 | } 87 | 88 | function _findNodeOnLine(line: number, ast: TestSuite) { 89 | let foundNode: Node; 90 | 91 | traverse(ast.settingsTable, { 92 | enter: (node: Node) => { 93 | if (foundNode) { 94 | return VisitorOption.Break; 95 | } 96 | if (typeGuards.isSettingsTable(node)) { 97 | return VisitorOption.Continue; 98 | } 99 | if (isOnLine(line, node)) { 100 | foundNode = node; 101 | 102 | return VisitorOption.Break; 103 | } 104 | 105 | return VisitorOption.Continue; 106 | }, 107 | }); 108 | 109 | return foundNode; 110 | } 111 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/suite-completions.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { TestSuite } from "../../parser/models"; 3 | import { Location } from "../../utils/position"; 4 | import { CompletionItem } from "vscode-languageserver"; 5 | import { getSyntaxCompletions } from "./completion-helper"; 6 | import { LocationInfo } from "../node-locator"; 7 | 8 | /** 9 | * Return the completions for data tables 10 | */ 11 | export function getCompletions( 12 | location: Location, 13 | locationInfo: LocationInfo, 14 | fileAst: TestSuite 15 | ): CompletionItem[] { 16 | const { row, cell, textBefore: text } = locationInfo; 17 | 18 | if (row.indexOf(cell) !== 0 || !text.startsWith("*")) { 19 | return []; 20 | } 21 | 22 | const missingTables = _getMissingTables(fileAst); 23 | const sanitizedText = text.replace(/\*/g, "").replace(/ /g, ""); 24 | return getSyntaxCompletions(sanitizedText, missingTables); 25 | } 26 | 27 | function _getMissingTables(ast: TestSuite) { 28 | const tables = []; 29 | 30 | if (!ast.settingsTable) { 31 | tables.push("Settings"); 32 | } 33 | if (!ast.variablesTable) { 34 | tables.push("Variables"); 35 | } 36 | if (!ast.keywordsTable) { 37 | tables.push("Keywords"); 38 | } 39 | if (!ast.testCasesTable) { 40 | tables.push("Test Cases"); 41 | } 42 | 43 | return tables; 44 | } 45 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/testcase-table-completions.ts: -------------------------------------------------------------------------------- 1 | import Workspace from "../workspace/workspace"; 2 | import { TestSuite } from "../../parser/models"; 3 | import { CompletionItem } from "vscode-languageserver"; 4 | import { Location } from "../../utils/position"; 5 | 6 | import * as functionCompletions from "./functions-table-completions"; 7 | import { LocationInfo } from "../node-locator"; 8 | 9 | const SETTINGS = [ 10 | "Documentation", 11 | "Tags", 12 | "Setup", 13 | "Teardown", 14 | "Template", 15 | "Timeout", 16 | ]; 17 | 18 | /** 19 | * Returns the completions for test case table 20 | * 21 | */ 22 | export function getCompletions( 23 | location: Location, 24 | locationInfo: LocationInfo, 25 | fileAst: TestSuite, 26 | workspace: Workspace 27 | ): CompletionItem[] { 28 | return functionCompletions.getCompletions( 29 | location, 30 | locationInfo, 31 | fileAst, 32 | workspace, 33 | SETTINGS 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /server/src/intellisense/completion-provider/variable-table-completions.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import Workspace from "../workspace/workspace"; 3 | import { TestSuite } from "../../parser/models"; 4 | import { Location } from "../../utils/position"; 5 | import { getVariableCompletions } from "./completion-helper"; 6 | import { CompletionItem } from "vscode-languageserver"; 7 | import { LocationInfo } from "../node-locator"; 8 | 9 | export function getCompletions( 10 | location: Location, 11 | locationInfo: LocationInfo, 12 | fileAst: TestSuite, 13 | workspace: Workspace 14 | ): CompletionItem[] { 15 | const { row, cell, textBefore } = locationInfo; 16 | 17 | const isFirstCell = row.indexOf(cell) === 0; 18 | if (isFirstCell) { 19 | return []; 20 | } 21 | 22 | return getVariableCompletions(textBefore, workspace.variables); 23 | } 24 | -------------------------------------------------------------------------------- /server/src/intellisense/formatters.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { VariableDeclaration, VariableKind } from "../parser/models"; 3 | 4 | export function formatVariable(variable: VariableDeclaration) { 5 | const identifier = _variableKindToIdentifier(variable.kind); 6 | const name = variable.id.name; 7 | 8 | return `${identifier}{${name}}`; 9 | } 10 | 11 | function _variableKindToIdentifier(kind: VariableKind) { 12 | switch (kind) { 13 | case "Scalar": 14 | return "$"; 15 | case "List": 16 | return "@"; 17 | case "Dictionary": 18 | return "&"; 19 | default: 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/intellisense/highlight-provider.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as typeGuards from "./type-guards"; 3 | import Workspace from "./workspace/workspace"; 4 | import { ConsoleLogger as logger } from "../logger"; 5 | import { DocumentHighlight } from "vscode-languageserver"; 6 | import { Location, nodeLocationToRange } from "../utils/position"; 7 | import { filter } from "../utils/ast-util"; 8 | import { findLocalVariables, findNodeInPos, FileNode } from "./node-locator"; 9 | import { 10 | Node, 11 | VariableDeclaration, 12 | VariableExpression, 13 | FunctionDeclaration, 14 | SettingDeclaration, 15 | } from "../parser/models"; 16 | 17 | /** 18 | * 19 | * @param location 20 | * @param workspace 21 | */ 22 | export function findFileHighlights(location: Location, workspace: Workspace) { 23 | const file = workspace.getFile(location.filePath); 24 | if (!file) { 25 | logger.info(`Definition not found. File '${location.filePath}' not parsed`); 26 | return []; 27 | } 28 | 29 | const nodeInPos = findNodeInPos(location.position, file); 30 | 31 | const variableHighlights = _tryFindVariableHighlights(nodeInPos); 32 | if (variableHighlights) { 33 | return variableHighlights; 34 | } 35 | 36 | const keywordHighlights = _tryFindKeywordHighlights(nodeInPos); 37 | if (keywordHighlights) { 38 | return keywordHighlights; 39 | } 40 | 41 | const settingHighlights = _tryFindSettingHighlights(nodeInPos); 42 | if (settingHighlights) { 43 | return settingHighlights; 44 | } 45 | // if (_isSettingDeclaration(nodeInPos)) { 46 | 47 | // } 48 | return []; 49 | } 50 | 51 | /** 52 | * Tries to find highlights for variables 53 | * 54 | * @param nodeInPos 55 | * 56 | * @return null if there isn't a variable in the position, 57 | * otherwise highlights for the variables 58 | */ 59 | function _tryFindVariableHighlights(nodeInPos: FileNode): DocumentHighlight[] { 60 | const lastNode = nodeInPos.node; 61 | const secondLast = _.last(nodeInPos.path); 62 | 63 | let variable: VariableDeclaration | VariableExpression; 64 | if (typeGuards.isVariableDeclaration(lastNode)) { 65 | variable = lastNode; 66 | } else if (typeGuards.isIdentifier(lastNode)) { 67 | if (typeGuards.isVariableDeclaration(secondLast)) { 68 | variable = secondLast; 69 | } else if (typeGuards.isVariableExpression(secondLast)) { 70 | variable = secondLast; 71 | } else { 72 | return null; 73 | } 74 | } else { 75 | return null; 76 | } 77 | 78 | const functionNode = _tryFindFunctionDeclaration(nodeInPos); 79 | let searchScope: Node = nodeInPos.file.ast; 80 | if (functionNode) { 81 | // Might be a local variable 82 | const locals = findLocalVariables(functionNode); 83 | const localVariable = locals.findVariable(variable.kind, variable.id.name); 84 | if (localVariable) { 85 | // Search only within the function 86 | searchScope = functionNode; 87 | } 88 | } 89 | 90 | return filter(searchScope, node => { 91 | if ( 92 | typeGuards.isVariableDeclaration(node) || 93 | typeGuards.isVariableExpression(node) 94 | ) { 95 | return ( 96 | node.kind === variable.kind && 97 | node.id.name.toLowerCase() === variable.id.name.toLowerCase() 98 | ); 99 | } else { 100 | return false; 101 | } 102 | }).map(node => { 103 | const highlightFor = typeGuards.isVariableDeclaration(node) 104 | ? node.id 105 | : node; 106 | return _createSymbolHighlight(highlightFor); 107 | }); 108 | } 109 | 110 | /** 111 | * Tries to find highlights for user keywords 112 | * 113 | * @param nodeInPos 114 | * 115 | * @return null if there isn't a variable in the position, 116 | * otherwise highlights for the variables 117 | */ 118 | function _tryFindKeywordHighlights(nodeInPos: FileNode): DocumentHighlight[] { 119 | const lastNode = nodeInPos.node; 120 | const secondLast = _.last(nodeInPos.path); 121 | 122 | let keywordName: string; 123 | if (!typeGuards.isIdentifier(lastNode)) { 124 | return null; 125 | } 126 | 127 | if (typeGuards.isCallExpression(secondLast)) { 128 | keywordName = secondLast.callee.name; 129 | } else if (typeGuards.isUserKeyword(secondLast)) { 130 | keywordName = secondLast.id.name; 131 | } else { 132 | return null; 133 | } 134 | 135 | // TODO: Support keywords with embedded arguments 136 | return filter(nodeInPos.file.ast, node => { 137 | if (typeGuards.isCallExpression(node)) { 138 | return node.callee.name.toLowerCase() === keywordName.toLowerCase(); 139 | } else if (typeGuards.isUserKeyword(node)) { 140 | return node.id.name.toLowerCase() === keywordName.toLowerCase(); 141 | } else { 142 | return false; 143 | } 144 | }).map(node => { 145 | if (typeGuards.isCallExpression(node)) { 146 | return _createSymbolHighlight(node.callee); 147 | } else if (typeGuards.isUserKeyword(node)) { 148 | return _createSymbolHighlight(node.id); 149 | } else { 150 | return undefined; 151 | } 152 | }); 153 | } 154 | 155 | function _tryFindSettingHighlights(nodeInPos: FileNode): DocumentHighlight[] { 156 | const lastNode = nodeInPos.node; 157 | const secondLast = _.last(nodeInPos.path); 158 | 159 | if ( 160 | !typeGuards.isIdentifier(lastNode) || 161 | !typeGuards.isSettingDeclaration(secondLast) 162 | ) { 163 | return null; 164 | } 165 | 166 | const settings = filter( 167 | nodeInPos.file.ast, 168 | node => 169 | typeGuards.isSettingDeclaration(node) && node.kind === secondLast.kind 170 | ) as SettingDeclaration[]; 171 | 172 | return settings.map(node => _createSymbolHighlight(node.id)); 173 | } 174 | 175 | function _tryFindFunctionDeclaration(nodeInPos: FileNode): FunctionDeclaration { 176 | return [nodeInPos.node, ...nodeInPos.path].find( 177 | typeGuards.isFunctionDeclaration 178 | ) as FunctionDeclaration; 179 | } 180 | 181 | function _createSymbolHighlight(node: Node): DocumentHighlight { 182 | return { 183 | range: nodeLocationToRange(node), 184 | }; 185 | } 186 | -------------------------------------------------------------------------------- /server/src/intellisense/keyword-matcher.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { UserKeyword, Identifier } from "../parser/models"; 3 | 4 | import { parseVariableString } from "../parser/primitive-parsers"; 5 | import { isNamespacedIdentifier } from "./type-guards"; 6 | 7 | // As per RF documentation, the variables are matched with .*? regex 8 | const ARGUMENT_REGEX = ".*?"; 9 | 10 | function sanitizeKeywordName(name: string) { 11 | return name.replace(/ /g, "").replace(/_/g, ""); 12 | } 13 | 14 | function createKeywordRegex(keywordName: string) { 15 | const sanitizedName = sanitizeKeywordName(keywordName); 16 | const parseResult = parseVariableString(sanitizedName); 17 | 18 | const regexParts = parseResult.map(result => { 19 | return result.kind === "var" 20 | ? ARGUMENT_REGEX 21 | : _.escapeRegExp(result.value); 22 | }); 23 | 24 | const regexString = `^${regexParts.join("")}\$`; 25 | 26 | // As per RF documentation, keywords are matched case-insensitive 27 | return new RegExp(regexString, "i"); 28 | } 29 | 30 | export function identifierMatchesKeyword( 31 | identifier: Identifier, 32 | keyword: UserKeyword 33 | ) { 34 | if (isNamespacedIdentifier(identifier)) { 35 | // When the identifier is explicit, the namespace must match the keyword case-insensitively. 36 | if ( 37 | identifier.namespace && 38 | identifier.namespace.toLowerCase() !== keyword.id.namespace.toLowerCase() 39 | ) { 40 | return false; 41 | } 42 | } 43 | 44 | const keywordName = keyword.id.name; 45 | const regex = createKeywordRegex(keywordName); 46 | 47 | const sanitizedIdentifierName = sanitizeKeywordName(identifier.name); 48 | return regex.test(sanitizedIdentifierName); 49 | } 50 | 51 | /** 52 | * Tests case-insensitively if two identifiers are the same 53 | * 54 | * @param identifier1 55 | * @param identifier2 56 | */ 57 | export function identifierMatchesIdentifier(x: Identifier, y: Identifier) { 58 | const regex = new RegExp(`^${_.escapeRegExp(x.name)}\$`, "i"); 59 | 60 | return regex.test(y.name); 61 | } 62 | -------------------------------------------------------------------------------- /server/src/intellisense/models.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Range as LangServerRange, 3 | Location as LangServerLocation, 4 | } from "vscode-languageserver"; 5 | 6 | export type Range = LangServerRange; 7 | 8 | export type Location = LangServerLocation; 9 | -------------------------------------------------------------------------------- /server/src/intellisense/node-locator.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import WorkspaceFile from "./workspace/workspace-file"; 3 | import { Node, FunctionDeclaration } from "../parser/models"; 4 | import * as typeGuards from "./type-guards"; 5 | import { DataTable, DataRow, DataCell } from "../parser/table-models"; 6 | import { traverse, VisitorOption } from "../traverse/traverse"; 7 | import { Location, Position, isInRange, Range } from "../utils/position"; 8 | import { VariableContainer } from "./search-tree"; 9 | 10 | export interface FileNode { 11 | file: WorkspaceFile; 12 | path: Node[]; 13 | node: Node; 14 | } 15 | 16 | export interface LocationInfo { 17 | row: DataRow; 18 | cell: DataCell; 19 | textBefore: string; 20 | textAfter: string; 21 | } 22 | 23 | /** 24 | * Find the most specific node in the given document position 25 | * 26 | * @param pos 27 | * @param fileToSearch 28 | */ 29 | export function findNodeInPos( 30 | pos: Position, 31 | fileToSearch: WorkspaceFile 32 | ): FileNode { 33 | const pathToNode: Node[] = []; 34 | let leafNode: Node | null = null; 35 | 36 | traverse(fileToSearch.ast, { 37 | enter: (node: Node, parent: Node) => { 38 | if (!isInRange(pos, node)) { 39 | return VisitorOption.Skip; 40 | } else { 41 | if (leafNode) { 42 | pathToNode.push(leafNode); 43 | } 44 | 45 | leafNode = node; 46 | } 47 | 48 | return VisitorOption.Continue; 49 | }, 50 | }); 51 | 52 | return { 53 | file: fileToSearch, 54 | path: pathToNode, 55 | node: leafNode, 56 | }; 57 | } 58 | 59 | /** 60 | * 61 | * @param location 62 | * @param tables 63 | */ 64 | export function findLocationInfo( 65 | location: Location, 66 | tables: DataTable[] 67 | ): LocationInfo { 68 | const isOnLine = (loc: Range) => 69 | loc.start.line <= location.position.line && 70 | location.position.line <= loc.end.line; 71 | const isOnCell = (loc: Range) => 72 | loc.start.column <= location.position.column && 73 | location.position.column <= loc.end.column; 74 | 75 | const table = tables.find(t => isOnLine(t.location)); 76 | if (!table) { 77 | return null; 78 | } 79 | 80 | const row = table.rows.find(r => isOnLine(r.location)) || table.header; 81 | 82 | const cell = row.cells.find(c => isOnCell(c.location)); 83 | let textBefore = ""; 84 | let textAfter = ""; 85 | if (cell) { 86 | const columnRelativeToCell = 87 | location.position.column - cell.location.start.column; 88 | textBefore = cell.content.substring(0, columnRelativeToCell); 89 | textAfter = cell.content.substring(columnRelativeToCell); 90 | } 91 | 92 | return { 93 | row, 94 | cell, 95 | textBefore, 96 | textAfter, 97 | }; 98 | } 99 | 100 | /** 101 | * Find local variables in a keyword or test case, including arguments. 102 | * If beforeLine is given, returns only those that are declared 103 | * before the given line. 104 | * 105 | * @param testCase 106 | * @param beforeLine 107 | */ 108 | export function findLocalVariables( 109 | functionNode: FunctionDeclaration, 110 | beforeLine?: number 111 | ) { 112 | const variables = new VariableContainer(); 113 | const isBeforeLine = (node: Node) => 114 | beforeLine === undefined || node.location.start.line < beforeLine; 115 | 116 | if (typeGuards.isUserKeyword(functionNode) && functionNode.arguments) { 117 | functionNode.arguments.values.forEach(arg => variables.add(arg)); 118 | } 119 | 120 | functionNode.steps.forEach(step => { 121 | if (typeGuards.isVariableDeclaration(step.body) && isBeforeLine(step)) { 122 | variables.add(step.body); 123 | } 124 | }); 125 | 126 | return variables; 127 | } 128 | -------------------------------------------------------------------------------- /server/src/intellisense/reference-finder.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { Node, UserKeyword } from "../parser/models"; 3 | import { traverse, VisitorOption } from "../traverse/traverse"; 4 | import { findKeywordDefinition } from "./definition-finder"; 5 | import Workspace from "./workspace/workspace"; 6 | import WorkspaceFile from "./workspace/workspace-file"; 7 | import { Location } from "../utils/position"; 8 | import { findNodeInPos } from "./node-locator"; 9 | import { nodeLocationToRange } from "../utils/position"; 10 | import { 11 | identifierMatchesKeyword, 12 | identifierMatchesIdentifier, 13 | } from "./keyword-matcher"; 14 | import { isIdentifier, isCallExpression, isUserKeyword } from "./type-guards"; 15 | 16 | interface VscodePosition { 17 | line: number; 18 | character: number; 19 | } 20 | 21 | interface VscodeRange { 22 | start: VscodePosition; 23 | end: VscodePosition; 24 | } 25 | 26 | interface VscodeLocation { 27 | uri: string; 28 | range: VscodeRange; 29 | } 30 | 31 | /** 32 | * Finds all references for the symbol in given document position 33 | * 34 | * @param location 35 | * @param workspace 36 | */ 37 | export function findReferences( 38 | location: Location, 39 | workspace: Workspace 40 | ): VscodeLocation[] { 41 | const file = workspace.getFile(location.filePath); 42 | if (!file) { 43 | return []; 44 | } 45 | 46 | const nodeInPos = findNodeInPos(location.position, file); 47 | if (!isIdentifier(nodeInPos.node)) { 48 | return []; 49 | } 50 | 51 | const parentOfNode = _.last(nodeInPos.path); 52 | if (isUserKeyword(parentOfNode)) { 53 | const searchedKeyword = parentOfNode; 54 | const isSearchedKeyword = createNodeKeywordMatcherFn(searchedKeyword); 55 | 56 | return findWorkspaceKeywordReferences(isSearchedKeyword, workspace).concat([ 57 | { 58 | uri: nodeInPos.file.uri, 59 | range: nodeLocationToRange(searchedKeyword), 60 | }, 61 | ]); 62 | } else if (isCallExpression(parentOfNode)) { 63 | const keywordDefinition = findKeywordDefinition( 64 | parentOfNode.callee, 65 | nodeInPos.file, 66 | workspace 67 | ); 68 | if (keywordDefinition) { 69 | const isSearchedKeyword = createNodeKeywordMatcherFn( 70 | keywordDefinition.node 71 | ); 72 | 73 | return findWorkspaceKeywordReferences( 74 | isSearchedKeyword, 75 | workspace 76 | ).concat([ 77 | { 78 | uri: keywordDefinition.uri, 79 | range: keywordDefinition.range, 80 | }, 81 | ]); 82 | } else { 83 | const isSearchedKeyword = (node: Node) => 84 | (isCallExpression(node) && 85 | identifierMatchesIdentifier(node.callee, parentOfNode.callee)) || 86 | (isUserKeyword(node) && 87 | identifierMatchesIdentifier(node.id, parentOfNode.callee)); 88 | 89 | return findWorkspaceKeywordReferences(isSearchedKeyword, workspace); 90 | } 91 | } 92 | 93 | return []; 94 | } 95 | 96 | /** 97 | * Returns a function that takes a node and checks if that 98 | * node is a call expression calling the given user keyword 99 | * 100 | * @param keywordToMatch 101 | */ 102 | function createNodeKeywordMatcherFn(keywordToMatch: UserKeyword) { 103 | return (node: Node) => 104 | isCallExpression(node) && 105 | identifierMatchesKeyword(node.callee, keywordToMatch); 106 | } 107 | 108 | function findWorkspaceKeywordReferences( 109 | isSearchedKeywordFn: (node: Node) => boolean, 110 | workspace: Workspace 111 | ): VscodeLocation[] { 112 | let references: VscodeLocation[] = []; 113 | 114 | for (const file of workspace.getFiles()) { 115 | const fileReferences = findFileKeywordReferences(isSearchedKeywordFn, file); 116 | 117 | references = references.concat(fileReferences); 118 | } 119 | 120 | return references; 121 | } 122 | 123 | function findFileKeywordReferences( 124 | isSearchedKeywordFn: (node: Node) => boolean, 125 | file: WorkspaceFile 126 | ) { 127 | // Optimize traversal by limiting which nodes to enter 128 | const nodesToEnter = new Set([ 129 | "TestSuite", 130 | "TestCasesTable", 131 | "TestCase", 132 | "Step", 133 | "Teardown", 134 | "Setup", 135 | "KeywordsTable", 136 | "UserKeyword", 137 | "ScalarDeclaration", 138 | "ListDeclaration", 139 | "DictionaryDeclaration", 140 | "SettingsTable", 141 | "SuiteSetting", 142 | ]); 143 | 144 | const references: VscodeLocation[] = []; 145 | 146 | traverse(file.ast, { 147 | enter: (node: Node, parent: Node) => { 148 | if (isSearchedKeywordFn(node)) { 149 | references.push({ 150 | uri: file.uri, 151 | range: nodeLocationToRange(node), 152 | }); 153 | 154 | return VisitorOption.Skip; 155 | } else if (!nodesToEnter.has(node.type)) { 156 | return VisitorOption.Skip; 157 | } 158 | 159 | return VisitorOption.Continue; 160 | }, 161 | }); 162 | 163 | return references; 164 | } 165 | -------------------------------------------------------------------------------- /server/src/intellisense/search-tree.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as Trie from "node-ternary-search-trie"; 3 | 4 | import { 5 | UserKeyword, 6 | VariableKind, 7 | VariableDeclaration, 8 | } from "../parser/models"; 9 | 10 | import { TestSuite } from "../parser/models"; 11 | 12 | abstract class SymbolContainer { 13 | protected tree: Trie = new Trie(); 14 | 15 | public add(item: T) { 16 | const normalizedKey = this._getNormalizedKey(item); 17 | 18 | this.tree.set(normalizedKey, item); 19 | } 20 | 21 | public get(key: string): T | undefined { 22 | return this.tree.get(key); 23 | } 24 | 25 | public getAll(): T[] { 26 | const all: T[] = []; 27 | 28 | this.forEach((key, item) => all.push(item)); 29 | 30 | return all; 31 | } 32 | 33 | public remove(item: T) { 34 | const normalizedKey = this._getNormalizedKey(item); 35 | 36 | this.tree.del(normalizedKey); 37 | } 38 | 39 | public forEach(cb: (key: string, item: T) => void) { 40 | this.tree.traverse((key: string, item: T) => { 41 | cb(key, item); 42 | }); 43 | } 44 | 45 | public filter(cb: (item: T) => boolean): T[] { 46 | const filtered: T[] = []; 47 | 48 | this.forEach((key, item) => { 49 | if (cb(item)) { 50 | filtered.push(item); 51 | } 52 | }); 53 | 54 | return filtered; 55 | } 56 | 57 | public findByPrefix(prefix: string): T[] { 58 | const found: T[] = []; 59 | const normalizedPrefix = this._normalizeKey(prefix); 60 | 61 | if (prefix.length === 0) { 62 | return this.getAll(); 63 | } 64 | 65 | this.tree.searchWithPrefix(normalizedPrefix, (key: any, keyword: T) => { 66 | found.push(keyword); 67 | }); 68 | 69 | return found; 70 | } 71 | 72 | public copyFrom(other: SymbolContainer) { 73 | other.forEach((key, item) => this.tree.set(key, item)); 74 | } 75 | 76 | public size() { 77 | return this.tree.size(); 78 | } 79 | 80 | protected abstract getKey(item: T): string; 81 | 82 | protected _getNormalizedKey(item: T) { 83 | const key = this.getKey(item); 84 | 85 | return this._normalizeKey(key); 86 | } 87 | 88 | protected _normalizeKey(key: string) { 89 | return key.toLowerCase(); 90 | } 91 | } 92 | 93 | /** 94 | * Container for keywords 95 | */ 96 | export class KeywordContainer extends SymbolContainer { 97 | protected getKey(item: UserKeyword) { 98 | return item.id.name; 99 | } 100 | } 101 | 102 | /** 103 | * Container for global keywords. Indexed without the namespace. 104 | * Keywords from different namespaces with the same name are grouped in an array. 105 | */ 106 | export class GlobalKeywordContainer extends SymbolContainer { 107 | public addKeyword(item: UserKeyword) { 108 | const key = this._getKeywordNormalizedKey(item); 109 | const existingKeywords = this.get(key) || []; 110 | 111 | this.tree.set(key, [...existingKeywords, item]); 112 | } 113 | 114 | public removeKeyword(item: UserKeyword) { 115 | const key = this._getKeywordNormalizedKey(item); 116 | const existingKeywords = this.get(key); 117 | 118 | if (!existingKeywords) { 119 | return; 120 | } 121 | 122 | const keywordsWithoutRemoved = existingKeywords.filter( 123 | keyword => keyword.id.fullName !== item.id.fullName 124 | ); 125 | 126 | if (keywordsWithoutRemoved.length > 0) { 127 | this.tree.set(key, keywordsWithoutRemoved); 128 | } else { 129 | this.tree.del(key); 130 | } 131 | } 132 | 133 | protected getKey(item: UserKeyword[]) { 134 | return item[0].id.name; 135 | } 136 | 137 | private _getKeywordNormalizedKey(item: UserKeyword) { 138 | return this._normalizeKey(item.id.name); 139 | } 140 | } 141 | 142 | /** 143 | * Container for variables 144 | */ 145 | export class VariableContainer extends SymbolContainer { 146 | public static Empty = new VariableContainer(); 147 | 148 | public findVariable(kind: string, name: string) { 149 | const matches = this.findByPrefix(name); 150 | if (matches.length === 0) { 151 | return null; 152 | } 153 | 154 | const possibleMatch = matches[0]; 155 | return possibleMatch.kind === kind ? possibleMatch : null; 156 | } 157 | 158 | protected getKey(item: VariableDeclaration) { 159 | return this._getVariableName(item); 160 | } 161 | 162 | private _getVariableName(node: VariableDeclaration) { 163 | const typeIdentifier = this._variableKindToIdentifier(node.kind); 164 | 165 | if (!typeIdentifier) { 166 | return node.id.name; 167 | } else { 168 | return `${typeIdentifier}{${node.id.name}}`; 169 | } 170 | } 171 | 172 | // TODO: Move to formatters 173 | private _variableKindToIdentifier(kind: VariableKind) { 174 | switch (kind) { 175 | case "Scalar": 176 | return "$"; 177 | case "List": 178 | return "@"; 179 | case "Dictionary": 180 | return "&"; 181 | default: 182 | return null; 183 | } 184 | } 185 | } 186 | 187 | export interface Symbols { 188 | namespace: string; 189 | documentation: string; 190 | keywords: KeywordContainer; 191 | variables: VariableContainer; 192 | } 193 | 194 | /** 195 | * Creates search trees for keywords and variables 196 | * 197 | * @param ast 198 | */ 199 | export function createFileSearchTrees(ast: TestSuite) { 200 | const keywords = new KeywordContainer(); 201 | const variables = new VariableContainer(); 202 | 203 | if (!ast) { 204 | return { 205 | documentation: "", 206 | keywords, 207 | variables, 208 | }; 209 | } 210 | 211 | if (ast.keywordsTable) { 212 | ast.keywordsTable.keywords.forEach(keyword => { 213 | keywords.add(keyword); 214 | }); 215 | } 216 | 217 | if (ast.variablesTable) { 218 | ast.variablesTable.variables.forEach(variable => { 219 | variables.add(variable); 220 | }); 221 | } 222 | 223 | const documentation = 224 | ast.settingsTable && 225 | ast.settingsTable.documentation && 226 | ast.settingsTable.documentation.value && 227 | ast.settingsTable.documentation.value.value; 228 | 229 | return { 230 | documentation, 231 | keywords, 232 | variables, 233 | }; 234 | } 235 | 236 | /** 237 | * Removes keywords and variables in given fileTree from given search trees 238 | * 239 | * @param searchTrees 240 | * @param ast 241 | */ 242 | export function removeFileSymbols(symbols: Symbols, ast: TestSuite) { 243 | // TODO: Could use another search trees instead of fileTree 244 | const { keywords, variables } = symbols; 245 | 246 | if (ast && ast.keywordsTable) { 247 | ast.keywordsTable.keywords.forEach(keyword => { 248 | keywords.remove(keyword); 249 | }); 250 | } 251 | 252 | if (ast && ast.variablesTable) { 253 | ast.variablesTable.variables.forEach(variable => { 254 | variables.remove(variable); 255 | }); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /server/src/intellisense/symbol-provider.ts: -------------------------------------------------------------------------------- 1 | import Workspace from "./workspace/workspace"; 2 | import WorkspaceFile from "./workspace/workspace-file"; 3 | import { nodeLocationToRange } from "../utils/position"; 4 | import { SymbolKind } from "vscode-languageserver"; 5 | import { formatVariable } from "./formatters"; 6 | import { isVariableDeclaration, isUserKeyword } from "./type-guards"; 7 | import { VariableDeclaration, TestCase, UserKeyword } from "../parser/models"; 8 | 9 | /** 10 | * Returns all symbols for given file 11 | * 12 | * @param filePath 13 | * @param workspaceTree 14 | */ 15 | export function getFileSymbols( 16 | file: WorkspaceFile, 17 | useFileNameAsContainer: boolean = false, 18 | query: string = "" 19 | ) { 20 | const idMatches = _createIdMatcherFn(query); 21 | const createVariableSymbol = (node: VariableDeclaration) => 22 | _createVariableSymbol(node, file, useFileNameAsContainer); 23 | const createKeywordSymbol = (node: UserKeyword) => 24 | _createKeywordSymbol(node, file, useFileNameAsContainer); 25 | const createTestCaseSymbol = (node: TestCase) => 26 | _createTestCaseSymbol(node, file, useFileNameAsContainer); 27 | 28 | const variableSymbols = file.variables 29 | .filter(idMatches) 30 | .map(createVariableSymbol); 31 | const keywordSymbols = file.keywords 32 | .filter(idMatches) 33 | .map(createKeywordSymbol); 34 | const testCases = file.ast.testCasesTable 35 | ? file.ast.testCasesTable.testCases 36 | : []; 37 | const testCaseSymbols = testCases.filter(idMatches).map(createTestCaseSymbol); 38 | 39 | return [...variableSymbols, ...keywordSymbols, ...testCaseSymbols]; 40 | } 41 | 42 | /** 43 | * Returns all symbols in the workspace that match the given search string 44 | * 45 | * @param workspace 46 | */ 47 | export function getWorkspaceSymbols(workspace: Workspace, query: string) { 48 | return Array.from(workspace.getFiles()) 49 | .map(files => getFileSymbols(files, true, query)) 50 | .reduce((fileSymbols, allSymbols) => { 51 | return allSymbols.concat(fileSymbols); 52 | }, []); 53 | } 54 | 55 | /** 56 | * Creates a function that checks if given identifier is 57 | * a match to given query string. Comparison is done 58 | * case insensitive. 59 | * 60 | * @param query 61 | * 62 | * @returns {function} 63 | */ 64 | function _createIdMatcherFn(query: string) { 65 | const lowerQuery = query.toLowerCase(); 66 | 67 | return (node: VariableDeclaration | UserKeyword | TestCase) => { 68 | if (query.includes(".") && isUserKeyword(node)) { 69 | // Query must be considered an explicit keyword to match this node. 70 | // Only keywords with namespaces shall match. 71 | if (node.id.namespace) { 72 | return node.id.fullName.toLowerCase().includes(lowerQuery); 73 | } 74 | } 75 | 76 | const toMatch = isVariableDeclaration(node) 77 | ? formatVariable(node) 78 | : node.id.name; 79 | 80 | return toMatch.toLowerCase().includes(lowerQuery); 81 | }; 82 | } 83 | 84 | function _createVariableSymbol( 85 | node: VariableDeclaration, 86 | file: WorkspaceFile, 87 | useFileNameAsContainer: boolean 88 | ) { 89 | return { 90 | name: formatVariable(node), 91 | kind: SymbolKind.Variable, 92 | location: { 93 | uri: file.uri, 94 | range: nodeLocationToRange(node), 95 | }, 96 | containerName: useFileNameAsContainer ? file.relativePath : undefined, 97 | }; 98 | } 99 | 100 | function _createTestCaseSymbol( 101 | node: TestCase, 102 | file: WorkspaceFile, 103 | useFileNameAsContainer: boolean 104 | ) { 105 | return { 106 | name: node.id.name, 107 | kind: SymbolKind.Function, 108 | location: { 109 | uri: file.uri, 110 | range: nodeLocationToRange(node), 111 | }, 112 | containerName: useFileNameAsContainer ? file.relativePath : "", 113 | }; 114 | } 115 | 116 | function _createKeywordSymbol( 117 | node: UserKeyword, 118 | file: WorkspaceFile, 119 | useFileNameAsContainer: boolean 120 | ) { 121 | return { 122 | name: node.id.name, 123 | kind: SymbolKind.Function, 124 | location: { 125 | uri: file.uri, 126 | range: nodeLocationToRange(node), 127 | }, 128 | containerName: useFileNameAsContainer ? file.relativePath : "", 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /server/src/intellisense/test/data/definition-finder.data.ts: -------------------------------------------------------------------------------- 1 | export const keywords = { 2 | filePath: "keywords.robot", 3 | relativePath: "keywords.robot", 4 | content: ` 5 | *** Settings *** 6 | Documentation Keywords for definition finder tests 7 | 8 | *** Keywords *** 9 | Simple Keyword 10 | [Documentation] Simple keyword 11 | 12 | Another Keyword 13 | [Documentation] Just another keyword 14 | `, 15 | }; 16 | 17 | export const tests = { 18 | filePath: "tests.robot", 19 | relativePath: "tests.robot", 20 | content: ` 21 | *** Settings *** 22 | [Documentation] Demonstrates keyword definition finding. 23 | Resource keywords.robot 24 | 25 | *** Test Cases *** 26 | Test cases for findinging keyword definition 27 | Simple Keyword 28 | Another Keyword 29 | 30 | Test cases for findinging gherking style keyword definition 31 | Given Simple Keyword 32 | When Simple Keyword 33 | Then Simple Keyword 34 | and Simple Keyword 35 | but Simple Keyword 36 | 37 | Test cases for not existing keywords 38 | Non Existing Keyword 39 | Given Non Existing Gherkin Keyword 40 | `, 41 | }; 42 | -------------------------------------------------------------------------------- /server/src/intellisense/test/definition-finder.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { findDefinition, NodeDefinition } from "../definition-finder"; 3 | import Workspace from "../workspace/workspace"; 4 | import { createRobotFile, RobotFile } from "../workspace/robot-file"; 5 | import { keywords, tests } from "./data/definition-finder.data"; 6 | 7 | const workspace = new Workspace(); 8 | 9 | const range = ( 10 | startLine: number, 11 | startColumn: number, 12 | endLine: number, 13 | endColumn: number 14 | ) => ({ 15 | start: { line: startLine, character: startColumn }, 16 | end: { line: endLine, character: endColumn }, 17 | }); 18 | 19 | describe("Definition finder", () => { 20 | let keywordsFile: RobotFile; 21 | let testsFile: RobotFile; 22 | 23 | before(() => { 24 | keywordsFile = createRobotFile( 25 | keywords.content, 26 | keywords.filePath, 27 | keywords.relativePath 28 | ); 29 | 30 | testsFile = createRobotFile( 31 | tests.content, 32 | tests.filePath, 33 | tests.relativePath 34 | ); 35 | 36 | workspace.addFile(keywordsFile); 37 | workspace.addFile(testsFile); 38 | }); 39 | 40 | describe("findDefinition", () => { 41 | describe("keywords", () => { 42 | const assertIsSimpleKeyword = (definition: NodeDefinition) => { 43 | assert.equal(definition.uri, `file:///${keywords.filePath}`); 44 | assert.deepEqual(definition.range, range(5, 0, 6, 33)); 45 | assert.equal( 46 | definition.node, 47 | keywordsFile.ast.keywordsTable.keywords[0] 48 | ); 49 | }; 50 | 51 | describe("simple keyword", () => { 52 | const runTest = (column: number) => { 53 | const actual = findDefinition( 54 | { 55 | filePath: "tests.robot", 56 | position: { 57 | line: 7, 58 | column, 59 | }, 60 | }, 61 | workspace 62 | ); 63 | 64 | assertIsSimpleKeyword(actual); 65 | }; 66 | 67 | it("beginning of keyword call", () => { 68 | runTest(2); 69 | }); 70 | 71 | it("middle of keyword call", () => { 72 | runTest(10); 73 | }); 74 | 75 | it("end of keyword call", () => { 76 | runTest(16); 77 | }); 78 | }); 79 | 80 | describe("gherking keywords", () => { 81 | const runTest = (line: number, column: number) => { 82 | const actual = findDefinition( 83 | { 84 | filePath: "tests.robot", 85 | position: { 86 | line, 87 | column, 88 | }, 89 | }, 90 | workspace 91 | ); 92 | 93 | assertIsSimpleKeyword(actual); 94 | }; 95 | 96 | it("works for given", () => { 97 | runTest(11, 2); 98 | }); 99 | 100 | it("works for when", () => { 101 | runTest(12, 2); 102 | }); 103 | 104 | it("works for then", () => { 105 | runTest(13, 2); 106 | }); 107 | 108 | it("works for and", () => { 109 | runTest(14, 2); 110 | }); 111 | 112 | it("works for but", () => { 113 | runTest(15, 2); 114 | }); 115 | }); 116 | 117 | it("non existing keyword", () => { 118 | const actual = findDefinition( 119 | { 120 | filePath: "tests.robot", 121 | position: { 122 | line: 18, 123 | column: 2, 124 | }, 125 | }, 126 | workspace 127 | ); 128 | 129 | assert.isNull(actual); 130 | }); 131 | 132 | it("non existing gherkin keyword", () => { 133 | const actual = findDefinition( 134 | { 135 | filePath: "tests.robot", 136 | position: { 137 | line: 19, 138 | column: 2, 139 | }, 140 | }, 141 | workspace 142 | ); 143 | 144 | assert.isNull(actual); 145 | }); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /server/src/intellisense/test/keyword-matcher.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { identifierMatchesKeyword } from "../keyword-matcher"; 5 | import { 6 | Identifier, 7 | NamespacedIdentifier, 8 | UserKeyword, 9 | } from "../../parser/models"; 10 | 11 | const dummyPos = { line: 0, column: 0 }; 12 | const dummyLoc = { start: dummyPos, end: dummyPos }; 13 | 14 | describe("Keyword matcher", () => { 15 | describe("identifierMatchesKeyword", () => { 16 | function shouldMatch(id: Identifier, kw: UserKeyword) { 17 | const result = identifierMatchesKeyword(id, kw); 18 | 19 | chai.assert.isTrue(result); 20 | } 21 | 22 | function shouldNotMatch(id: Identifier, kw: UserKeyword) { 23 | const result = identifierMatchesKeyword(id, kw); 24 | 25 | chai.assert.isFalse(result); 26 | } 27 | 28 | const identifier = (name: string) => new Identifier(name, dummyLoc); 29 | const keyword = (name: string) => 30 | new UserKeyword(nsIdentifier("", name), dummyPos); 31 | const nsIdentifier = (namespace: string, name: string) => 32 | new NamespacedIdentifier(namespace, name, dummyLoc); 33 | const nsKeyword = (namespace: string, name: string) => 34 | new UserKeyword(nsIdentifier(namespace, name), dummyPos); 35 | 36 | it("should match identifier to user keyword with same name", () => { 37 | shouldMatch(identifier("Keyword Name"), keyword("Keyword Name")); 38 | }); 39 | 40 | it("should match case-insensitively", () => { 41 | shouldMatch(identifier("Keyword Name"), keyword("keyword name")); 42 | }); 43 | 44 | it("should ignore spaces when matching", () => { 45 | shouldMatch(identifier("I shall call you"), keyword("iShallCallYou")); 46 | shouldMatch( 47 | identifier("I shall call you"), 48 | keyword("i ShallCall You") 49 | ); 50 | }); 51 | 52 | it("should ignore underscores when matching", () => { 53 | shouldMatch(identifier("I shall call you"), keyword("i_shall_call_you")); 54 | shouldMatch(identifier("IShallCallYou"), keyword("i___shall_call_you")); 55 | }); 56 | 57 | it("should not match when identifier is only partial of keyword", () => { 58 | shouldNotMatch( 59 | identifier("Partial of"), 60 | keyword("Partial of longer keyword") 61 | ); 62 | }); 63 | 64 | it("should not match when keyword is only partial of identifier", () => { 65 | shouldNotMatch( 66 | identifier("Partial of longer keyword"), 67 | keyword("Partial of") 68 | ); 69 | }); 70 | 71 | it("should not match when identifier is only partial of keyword with embedded arguments", () => { 72 | shouldNotMatch( 73 | identifier("Partial of"), 74 | keyword("Partial of keyword ${with} @{args}") 75 | ); 76 | }); 77 | 78 | it("should not match when keyword with embedded arguments is only partial of identifier", () => { 79 | shouldNotMatch( 80 | identifier("Partial with ${embedded} args longer keyword"), 81 | keyword("Partial with ${embedded} args") 82 | ); 83 | }); 84 | 85 | it("should match name with embedded arguments", () => { 86 | shouldMatch( 87 | identifier(`Keyword "with" embedded "args"`), 88 | keyword("Keyword ${arg1} embedded ${arg2}") 89 | ); 90 | 91 | shouldMatch( 92 | identifier(`Keyword with embedded args`), 93 | keyword("Keyword ${arg1} embedded ${arg2}") 94 | ); 95 | 96 | shouldMatch( 97 | identifier(`Keyword \${VAR} embedded \${VAR2}`), 98 | keyword("Keyword ${arg1} embedded ${arg2}") 99 | ); 100 | }); 101 | 102 | it("should work with keywords with reserved regex characters", () => { 103 | const createMatchTest = (value: string) => 104 | shouldMatch(identifier(value), keyword(value)); 105 | 106 | createMatchTest("Keyword ^ $ . * + ? ( ) [ ] { } |"); 107 | createMatchTest("Keyword[a-z|c?d]"); 108 | }); 109 | 110 | describe("with explicit keywords", () => { 111 | it("should match exactly", () => { 112 | shouldMatch( 113 | nsIdentifier("MyLibrary", "Keyword"), 114 | nsKeyword("MyLibrary", "Keyword") 115 | ); 116 | }); 117 | 118 | it("should match case-insensitively", () => { 119 | shouldMatch( 120 | nsIdentifier("mylibrary", "Keyword"), 121 | nsKeyword("MyLibrary", "Keyword") 122 | ); 123 | }); 124 | 125 | describe("should not match if namespace includes the special character", () => { 126 | it("", () => 127 | shouldNotMatch( 128 | nsIdentifier("My Library", "Keyword"), 129 | nsKeyword("MyLibrary", "Keyword") 130 | )); 131 | it("'.'", () => 132 | shouldNotMatch( 133 | nsIdentifier("My.Library", "Keyword"), 134 | nsKeyword("MyLibrary", "Keyword") 135 | )); 136 | it("'_'", () => 137 | shouldNotMatch( 138 | nsIdentifier("My_Library", "Keyword"), 139 | nsKeyword("MyLibrary", "Keyword") 140 | )); 141 | }); 142 | 143 | it("should match any explicit keyword when not fully specified", () => { 144 | shouldMatch( 145 | identifier("The Keyword"), 146 | nsKeyword("MyLibrary", "The Keyword") 147 | ); 148 | shouldMatch( 149 | identifier("The Keyword"), 150 | nsKeyword("com.company.Library", "The Keyword") 151 | ); 152 | }); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /server/src/intellisense/type-guards.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Node, 3 | Step, 4 | Identifier, 5 | NamespacedIdentifier, 6 | Literal, 7 | TemplateLiteral, 8 | VariableExpression, 9 | CallExpression, 10 | VariableDeclaration, 11 | ScalarDeclaration, 12 | ListDeclaration, 13 | DictionaryDeclaration, 14 | UserKeyword, 15 | TestCase, 16 | FunctionDeclaration, 17 | VariablesTable, 18 | SettingsTable, 19 | Documentation, 20 | SuiteSetting, 21 | Tags, 22 | Arguments, 23 | Timeout, 24 | Return, 25 | Teardown, 26 | Setup, 27 | Template, 28 | SettingDeclaration, 29 | } from "../parser/models"; 30 | 31 | function isOfType(node: Node, typeName: string) { 32 | return node && node.type === typeName; 33 | } 34 | 35 | export function isIdentifier(node: Node): node is Identifier { 36 | return isNamespacedIdentifier(node) || isOfType(node, "Identifier"); 37 | } 38 | 39 | export function isNamespacedIdentifier( 40 | node: Node 41 | ): node is NamespacedIdentifier { 42 | return isOfType(node, "NamespacedIdentifier"); 43 | } 44 | 45 | export function isVariableExpression(node: Node): node is VariableExpression { 46 | return isOfType(node, "VariableExpression"); 47 | } 48 | 49 | export function isCallExpression(node: Node): node is CallExpression { 50 | return isOfType(node, "CallExpression"); 51 | } 52 | 53 | export function isScalarDeclaration(node: Node): node is ScalarDeclaration { 54 | return isOfType(node, "ScalarDeclaration"); 55 | } 56 | 57 | export function isListDeclaration(node: Node): node is ListDeclaration { 58 | return isOfType(node, "ListDeclaration"); 59 | } 60 | 61 | export function isDictionaryDeclaration( 62 | node: Node 63 | ): node is DictionaryDeclaration { 64 | return isOfType(node, "DictionaryDeclaration"); 65 | } 66 | 67 | export function isVariableDeclaration(node: Node): node is VariableDeclaration { 68 | return ( 69 | isScalarDeclaration(node) || 70 | isListDeclaration(node) || 71 | isDictionaryDeclaration(node) 72 | ); 73 | } 74 | 75 | export function isStep(node: Node): node is Step { 76 | return isOfType(node, "Step"); 77 | } 78 | 79 | export function isUserKeyword(node: Node): node is UserKeyword { 80 | return isOfType(node, "UserKeyword"); 81 | } 82 | 83 | export function isTestCase(node: Node): node is TestCase { 84 | return isOfType(node, "TestCase"); 85 | } 86 | 87 | export function isFunctionDeclaration(node: Node): node is FunctionDeclaration { 88 | return isUserKeyword(node) || isTestCase(node); 89 | } 90 | 91 | export function isVariablesTable(node: Node): node is VariablesTable { 92 | return isOfType(node, "VariablesTable"); 93 | } 94 | 95 | export function isLiteral(node: Node): node is Literal { 96 | return isOfType(node, "Literal"); 97 | } 98 | 99 | export function isTemplateLiteral(node: Node): node is TemplateLiteral { 100 | return isOfType(node, "TemplateLiteral"); 101 | } 102 | 103 | export function isSettingsTable(node: Node): node is SettingsTable { 104 | return isOfType(node, "SettingsTable"); 105 | } 106 | 107 | export function isDocumentation(node: Node): node is Documentation { 108 | return isOfType(node, "Documentation"); 109 | } 110 | 111 | export function isSuiteSetting(node: Node): node is SuiteSetting { 112 | return isOfType(node, "SuiteSetting"); 113 | } 114 | 115 | export function isTags(node: Node): node is Tags { 116 | return isOfType(node, "Tags"); 117 | } 118 | 119 | export function isArguments(node: Node): node is Arguments { 120 | return isOfType(node, "Arguments"); 121 | } 122 | 123 | export function isTimeout(node: Node): node is Timeout { 124 | return isOfType(node, "Timeout"); 125 | } 126 | 127 | export function isReturn(node: Node): node is Return { 128 | return isOfType(node, "Return"); 129 | } 130 | 131 | export function isSetup(node: Node): node is Setup { 132 | return isOfType(node, "Setup"); 133 | } 134 | 135 | export function isTeardown(node: Node): node is Teardown { 136 | return isOfType(node, "Teardown"); 137 | } 138 | 139 | export function isTemplate(node: Node): node is Template { 140 | return isOfType(node, "Template"); 141 | } 142 | 143 | export function isSettingDeclaration(node: Node): node is SettingDeclaration { 144 | return ( 145 | isDocumentation(node) || 146 | isArguments(node) || 147 | isReturn(node) || 148 | isTimeout(node) || 149 | isTags(node) || 150 | isTeardown(node) || 151 | isSetup(node) || 152 | isTemplate(node) 153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /server/src/intellisense/workspace/library.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserKeyword, 3 | NamespacedIdentifier, 4 | Documentation, 5 | Identifier, 6 | Arguments, 7 | ScalarDeclaration, 8 | Literal, 9 | } from "../../parser/models"; 10 | import { location, position } from "../../parser/position-helper"; 11 | import { LibraryDefinition, KeywordDefinition } from "../../utils/settings"; 12 | import { Symbols, VariableContainer, KeywordContainer } from "../search-tree"; 13 | 14 | const DUMMY_POSITION = position(0, 0); 15 | const DUMMY_LOCATION = location(0, 0, 0, 0); 16 | 17 | /** 18 | * A standard or 3rd party library. Contains only keyword definitions 19 | * of that library. 20 | */ 21 | export class Library implements Symbols { 22 | public readonly variables = VariableContainer.Empty; 23 | public readonly keywords = new KeywordContainer(); 24 | 25 | constructor( 26 | public readonly namespace: string, 27 | public readonly version: string, 28 | public readonly documentation: string, 29 | keywords: UserKeyword[] 30 | ) { 31 | keywords.forEach(kw => this.keywords.add(kw)); 32 | } 33 | } 34 | 35 | /** 36 | * Parses a library file 37 | */ 38 | export function createLibraryFile( 39 | libraryDefinition: LibraryDefinition 40 | ): Library { 41 | const { name = "", version = "", keywords = [] } = libraryDefinition; 42 | 43 | const parsedKeywords = keywords 44 | .filter(kw => kw && kw.name) 45 | .map(kw => _jsonKeywordToModel(name, kw)); 46 | 47 | return new Library(name, version, "", parsedKeywords); 48 | } 49 | 50 | function _jsonKeywordToModel( 51 | namespace: string, 52 | keywordDefinition: KeywordDefinition 53 | ): UserKeyword { 54 | const { name, args = [], doc = "" } = keywordDefinition; 55 | 56 | const keyword = new UserKeyword( 57 | new NamespacedIdentifier(namespace, name, DUMMY_LOCATION), 58 | DUMMY_POSITION 59 | ); 60 | 61 | keyword.documentation = new Documentation( 62 | new Identifier("Documentation", DUMMY_LOCATION), 63 | new Literal(doc, DUMMY_LOCATION), 64 | DUMMY_LOCATION 65 | ); 66 | 67 | const parsedArgs = Array.isArray(args) ? args : [args]; 68 | 69 | keyword.arguments = new Arguments( 70 | new Identifier("Arguments", DUMMY_LOCATION), 71 | parsedArgs.map( 72 | arg => 73 | new ScalarDeclaration( 74 | new Identifier(arg, DUMMY_LOCATION), 75 | undefined, 76 | DUMMY_LOCATION 77 | ) 78 | ), 79 | DUMMY_LOCATION 80 | ); 81 | 82 | return keyword; 83 | } 84 | -------------------------------------------------------------------------------- /server/src/intellisense/workspace/python-file.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { TestSuite } from "../../parser/models"; 3 | import WorkspaceFile from "./workspace-file"; 4 | 5 | import { PythonParser } from "../../python-parser/python-parser"; 6 | 7 | const pythonParser = new PythonParser(); 8 | 9 | export class PythonFile extends WorkspaceFile { 10 | constructor( 11 | // The namespace for this file is based on the filename. 12 | namespace: string, 13 | // Absolute path of the file in the file system 14 | filePath: string, 15 | // File's relative path to workspace root 16 | relativePath: string, 17 | // AST of the file 18 | fileTree: TestSuite 19 | ) { 20 | super(namespace, filePath, relativePath, fileTree); 21 | } 22 | } 23 | 24 | /** 25 | * Parses a python file 26 | * 27 | * @param absolutePath 28 | * @param relativePath 29 | * @param contents 30 | */ 31 | export function createPythonFile( 32 | contents: string, 33 | absolutePath: string, 34 | relativePath: string 35 | ): PythonFile { 36 | // TODO: Is this how namespaces work for python files? 37 | const namespace = path.parse(absolutePath).name; 38 | const ast = pythonParser.parseFile(contents, namespace); 39 | 40 | return new PythonFile(namespace, absolutePath, relativePath, ast); 41 | } 42 | -------------------------------------------------------------------------------- /server/src/intellisense/workspace/robot-file.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { TestSuite } from "../../parser/models"; 3 | import { DataTable } from "../../parser/table-models"; 4 | import { FileParser as RobotParser } from "../../parser/parser"; 5 | import WorkspaceFile from "./workspace-file"; 6 | 7 | const robotParser = new RobotParser(); 8 | 9 | export class RobotFile extends WorkspaceFile { 10 | constructor( 11 | // The namespace for this file is based on the filename. 12 | namespace: string, 13 | // Absolute path of the file in the file system 14 | filePath: string, 15 | // File's relative path to workspace root 16 | relativePath: string, 17 | // AST of the file 18 | fileAst: TestSuite, 19 | // Tables read from the robot file 20 | public tables: DataTable[] 21 | ) { 22 | super(namespace, filePath, relativePath, fileAst); 23 | } 24 | } 25 | 26 | /** 27 | * Parses a robot file 28 | * 29 | * @param absolutePath 30 | * @param relativePath 31 | * @param contents 32 | */ 33 | export function createRobotFile( 34 | contents: string, 35 | absolutePath: string, 36 | relativePath: string 37 | ): RobotFile { 38 | const tables = robotParser.readTables(contents); 39 | 40 | // Set the namespace for all keywords to the file name. 41 | // Robot docs: 42 | // Resource files are specified in the full keyword name, similarly as library names. 43 | // The name of the resource is derived from the basename of the resource file without the file extension. 44 | // http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#handling-keywords-with-same-names 45 | const namespace = path.parse(relativePath).name; 46 | 47 | const ast = robotParser.parseFile(tables, namespace); 48 | 49 | return new RobotFile(namespace, absolutePath, relativePath, ast, tables); 50 | } 51 | -------------------------------------------------------------------------------- /server/src/intellisense/workspace/workspace-file.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { TestSuite } from "../../parser/models"; 3 | import { 4 | Symbols, 5 | createFileSearchTrees, 6 | KeywordContainer, 7 | VariableContainer, 8 | } from "../search-tree"; 9 | 10 | import { URI } from "vscode-uri"; 11 | 12 | abstract class WorkspaceFile implements Symbols { 13 | // All the variables in the file 14 | public variables: VariableContainer; 15 | 16 | // All the keywords in the file 17 | public keywords: KeywordContainer; 18 | 19 | public documentation: string; 20 | 21 | constructor( 22 | // The namespace for this file is based on the filename. 23 | public namespace: string, 24 | // Absolute path of the file in the file system 25 | public filePath: string, 26 | // File's relative path to workspace root 27 | public relativePath: string, 28 | // AST of the file 29 | public ast: TestSuite 30 | ) { 31 | const { documentation, keywords, variables } = createFileSearchTrees(ast); 32 | 33 | this.documentation = documentation; 34 | this.keywords = keywords; 35 | this.variables = variables; 36 | } 37 | 38 | public get uri() { 39 | return URI.file(this.filePath).toString(); 40 | } 41 | } 42 | 43 | export default WorkspaceFile; 44 | 45 | export type WorkspaceFileParserFn = ( 46 | contents: string, 47 | absolutePath: string, 48 | relativePath: string 49 | ) => WorkspaceFile; 50 | -------------------------------------------------------------------------------- /server/src/intellisense/workspace/workspace.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import WorkspaceFile from "./workspace-file"; 3 | import { 4 | GlobalKeywordContainer, 5 | VariableContainer, 6 | Symbols, 7 | } from "../search-tree"; 8 | import { UserKeyword } from "../../parser/models"; 9 | import { Library } from "./library"; 10 | 11 | /** 12 | * A class that represents a workspace (=folder) open in VSCode 13 | */ 14 | export class Workspace { 15 | // A tree of all global variables in the workspace 16 | public variables = new VariableContainer(); 17 | 18 | // A tree of all global keywords in the workspace 19 | private keywords = new GlobalKeywordContainer(); 20 | 21 | // Mapping from filename: string -> file 22 | private filesByPath: Map = new Map(); 23 | 24 | // Mapping from WorkspaceFile namespace: string -> file 25 | private filesByNamespace: Map = new Map(); 26 | 27 | // Mapping from library name --> Library 28 | private librariesByName: Map = new Map(); 29 | 30 | /** 31 | * Adds a file to the workspace 32 | * 33 | * @param file 34 | */ 35 | public addFile(file: WorkspaceFile) { 36 | // Remove file first so its search tree is removed from global tree 37 | this.removeFileByPath(file.filePath); 38 | 39 | file.keywords.forEach((key, keyword) => this.keywords.addKeyword(keyword)); 40 | this.variables.copyFrom(file.variables); 41 | 42 | this.filesByPath.set(file.filePath, file); 43 | this.filesByNamespace.set(file.namespace, file); 44 | } 45 | 46 | /** 47 | * Adds given library and its keywords to the workspace 48 | */ 49 | public addLibrary(library: Library) { 50 | library.keywords.forEach((key, keyword) => 51 | this.keywords.addKeyword(keyword) 52 | ); 53 | this.variables.copyFrom(library.variables); 54 | 55 | this.librariesByName.set(library.namespace, library); 56 | } 57 | 58 | /** 59 | * Removes all libraries and their keywords from the workspace 60 | */ 61 | public removeAllLibraries() { 62 | Array.from(this.librariesByName.values()).forEach(library => { 63 | library.keywords.forEach((key, keyword) => 64 | this.keywords.removeKeyword(keyword) 65 | ); 66 | 67 | this.librariesByName.delete(library.namespace); 68 | }); 69 | } 70 | 71 | /** 72 | * 73 | * @param filePath 74 | */ 75 | public removeFileByPath(filePath: string) { 76 | const existingFile = this.filesByPath.get(filePath); 77 | if (existingFile) { 78 | existingFile.keywords.forEach((key, keyword) => 79 | this.keywords.removeKeyword(keyword) 80 | ); 81 | const { ast } = existingFile; 82 | if (ast && ast.variablesTable) { 83 | ast.variablesTable.variables.forEach(variable => { 84 | this.variables.remove(variable); 85 | }); 86 | } 87 | this.filesByNamespace.delete(existingFile.namespace); 88 | } 89 | 90 | this.filesByPath.delete(filePath); 91 | } 92 | 93 | /** 94 | * Searchs the global workspace for matching keywords. 95 | * Results are grouped by the matching keyword. 96 | */ 97 | public findKeywords(textToSearch: string): UserKeyword[][] { 98 | /* An example of the resulting array-of-arrays: 99 | * [ 100 | * [ 101 | * { namespace: "Foo": keyword: "Find" }, 102 | * { namespace: "Bar": keyword: "Find" }, 103 | * ], 104 | * [ 105 | * { namespace: "Bar": keyword: "DoThing" }, 106 | * ] 107 | * ] 108 | */ 109 | return _(this.keywords.findByPrefix(textToSearch)) 110 | .flatten() 111 | .groupBy((keyword: UserKeyword) => keyword.id.name) 112 | .map((keywords: UserKeyword[]) => keywords) 113 | .value(); 114 | } 115 | 116 | /** 117 | * Finds modules (resource files and libraries) by their namespace 118 | */ 119 | public findModulesByNamespace(textToSearch: string): Symbols[] { 120 | const modules: Symbols[] = []; 121 | const normalizedSearchText = textToSearch.toLowerCase(); 122 | 123 | for (const file of this.filesByNamespace.values()) { 124 | if (file.namespace.toLowerCase().startsWith(normalizedSearchText)) { 125 | modules.push(file); 126 | } 127 | } 128 | 129 | for (const libraryNamespace of this.librariesByName.values()) { 130 | if ( 131 | libraryNamespace.namespace 132 | .toLowerCase() 133 | .startsWith(normalizedSearchText) 134 | ) { 135 | modules.push(libraryNamespace); 136 | } 137 | } 138 | 139 | return modules; 140 | } 141 | 142 | /** 143 | * Removes all files 144 | */ 145 | public clear() { 146 | this.filesByPath = new Map(); 147 | this.filesByNamespace = new Map(); 148 | this.librariesByName = new Map(); 149 | this.keywords = new GlobalKeywordContainer(); 150 | this.variables = new VariableContainer(); 151 | } 152 | 153 | public getFile(filename: string) { 154 | return this.filesByPath.get(filename); 155 | } 156 | 157 | public getFileByNamespace(namespace: string) { 158 | return this.filesByNamespace.get(namespace); 159 | } 160 | 161 | public getSymbolsByNamespace(namespace: string): Symbols { 162 | // Assume there's only one resource file / library per namespace 163 | return ( 164 | this.filesByNamespace.get(namespace) || 165 | this.librariesByName.get(namespace) 166 | ); 167 | } 168 | 169 | public getFiles() { 170 | return this.filesByPath.values(); 171 | } 172 | } 173 | 174 | export default Workspace; 175 | -------------------------------------------------------------------------------- /server/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { Config, LogLevel } from "./utils/settings"; 2 | 3 | export class ConsoleLogger { 4 | public static error(message?: any, ...optionalParams: any[]) { 5 | if (this.shouldLog(LogLevel.Errors)) { 6 | this.log(console.error, message, optionalParams); 7 | } 8 | } 9 | 10 | public static info(message?: any, ...optionalParams: any[]) { 11 | if (this.shouldLog(LogLevel.Info)) { 12 | this.log(console.info, message, optionalParams); 13 | } 14 | } 15 | 16 | public static debug(message?: any, ...optionalParams: any[]) { 17 | if (this.shouldLog(LogLevel.Debug)) { 18 | this.log(console.log, message, optionalParams); 19 | } 20 | } 21 | 22 | private static log( 23 | logFn: typeof console.log, 24 | message: string, 25 | optionalParams: any[] 26 | ) { 27 | if (optionalParams.length > 0) { 28 | logFn(message, ...optionalParams); 29 | } else { 30 | logFn(message); 31 | } 32 | } 33 | private static shouldLog(minLevel: LogLevel) { 34 | return Config.getLogLevel() >= minLevel; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/src/parser/function-parsers.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import * as positionHelper from "./position-helper"; 4 | import { DataCell } from "./table-models"; 5 | import { Step } from "./models"; 6 | import { parseCallExpression } from "./primitive-parsers"; 7 | 8 | import { 9 | isVariable, 10 | parseTypeAndName, 11 | parseVariableDeclaration, 12 | } from "./variable-parsers"; 13 | 14 | export function parseStep( 15 | firstDataCell: DataCell, 16 | restDataCells: DataCell[] 17 | ): Step { 18 | let stepContent; 19 | 20 | const lastCell = _.last(restDataCells) || firstDataCell; 21 | const stepLocation = positionHelper.locationFromStartEnd( 22 | firstDataCell.location, 23 | lastCell.location 24 | ); 25 | 26 | if (isVariable(firstDataCell)) { 27 | const typeAndName = parseTypeAndName(firstDataCell); 28 | const callExpression = parseCallExpression(restDataCells); 29 | 30 | stepContent = parseVariableDeclaration( 31 | typeAndName, 32 | [callExpression], 33 | stepLocation 34 | ); 35 | } else { 36 | stepContent = parseCallExpression([firstDataCell, ...restDataCells]); 37 | } 38 | 39 | return new Step(stepContent, stepLocation); 40 | } 41 | -------------------------------------------------------------------------------- /server/src/parser/keywords-table-parser.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { TableRowIterator } from "./row-iterator"; 4 | import { DataTable, DataRow, DataCell } from "./table-models"; 5 | 6 | import { 7 | KeywordsTable, 8 | UserKeyword, 9 | SettingDeclaration, 10 | NamespacedIdentifier, 11 | } from "./models"; 12 | 13 | import * as SettingParser from "./setting-parser"; 14 | 15 | import { parseIdentifier } from "./primitive-parsers"; 16 | 17 | import { parseStep } from "./function-parsers"; 18 | 19 | const keywordSettings = new Set([ 20 | "[Documentation]", 21 | "[Arguments]", 22 | "[Return]", 23 | "[Teardown]", 24 | "[Tags]", 25 | "[Timeout]", 26 | ]); 27 | 28 | export function parseKeywordsTable( 29 | dataTable: DataTable, 30 | namespace: string 31 | ): KeywordsTable { 32 | const keywordsTable = new KeywordsTable(dataTable.location); 33 | let currentKeyword: UserKeyword; 34 | 35 | const iterator = new TableRowIterator(dataTable); 36 | while (!iterator.isDone()) { 37 | const row = iterator.takeRow(); 38 | if (row.isEmpty()) { 39 | continue; 40 | } 41 | 42 | if (startsKeyword(row)) { 43 | const identifier = parseIdentifier(row.first()); 44 | const namespacedIdentifier = new NamespacedIdentifier( 45 | namespace, 46 | identifier.name, 47 | identifier.location 48 | ); 49 | 50 | currentKeyword = new UserKeyword( 51 | namespacedIdentifier, 52 | row.location.start 53 | ); 54 | keywordsTable.addKeyword(currentKeyword); 55 | } else if (currentKeyword) { 56 | const firstRowDataCells = row.getCellsByRange(1); 57 | const continuedRows = iterator.takeRowWhile(rowContinues); 58 | const continuedCells = joinRows(continuedRows); 59 | const [firstCell, ...restCells] = 60 | firstRowDataCells.concat(continuedCells); 61 | 62 | if (keywordSettings.has(firstCell.content)) { 63 | const setting = SettingParser.parseSetting(firstCell, restCells); 64 | 65 | setKeywordSetting(currentKeyword, setting); 66 | currentKeyword.location.end = setting.location.end; 67 | } else { 68 | const step = parseStep(firstCell, restCells); 69 | currentKeyword.addStep(step); 70 | currentKeyword.location.end = step.location.end; 71 | } 72 | } 73 | } 74 | 75 | return keywordsTable; 76 | } 77 | 78 | function startsKeyword(row: DataRow) { 79 | return !row.first().isEmpty(); 80 | } 81 | 82 | function rowContinues(row: DataRow) { 83 | return row.isRowContinuation({ 84 | requireFirstEmpty: true, 85 | }); 86 | } 87 | 88 | function joinRows(rows: DataRow[]): DataCell[] { 89 | const shouldTakeCell = (cell: DataCell) => !cell.isRowContinuation(); 90 | 91 | return rows.reduce((allCells, row) => { 92 | const rowCells = _.takeRightWhile(row.cells, shouldTakeCell); 93 | 94 | return allCells.concat(rowCells); 95 | }, []); 96 | } 97 | 98 | function setKeywordSetting(keyword: UserKeyword, setting: SettingDeclaration) { 99 | if (SettingParser.isDocumentation(setting)) { 100 | keyword.documentation = setting; 101 | } else if (SettingParser.isArguments(setting)) { 102 | keyword.arguments = setting; 103 | } else if (SettingParser.isReturn(setting)) { 104 | keyword.return = setting; 105 | } else if (SettingParser.isTimeout(setting)) { 106 | keyword.timeout = setting; 107 | } else if (SettingParser.isTeardown(setting)) { 108 | keyword.teardown = setting; 109 | } else if (SettingParser.isTags(setting)) { 110 | keyword.tags = setting; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /server/src/parser/parser.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { TestSuite } from "./models"; 4 | import { DataTable } from "./table-models"; 5 | 6 | import { TableReader } from "./table-reader"; 7 | import { parseSettingsTable } from "./settings-table-parser"; 8 | import { parseKeywordsTable } from "./keywords-table-parser"; 9 | import { parseVariablesTable } from "./variables-table-parser"; 10 | import { parseTestCasesTable } from "./test-cases-table-parser"; 11 | 12 | const SETTINGS_TABLES = new Set(["setting", "settings"]); 13 | const VARIABLES_TABLES = new Set(["variable", "variables"]); 14 | const KEYWORDS_TABLES = new Set(["keyword", "keywords"]); 15 | const TEST_CASES_TABLES = new Set(["test case", "test cases"]); 16 | 17 | export class FileParser { 18 | public readTables(data: string) { 19 | const tableReader = new TableReader(); 20 | 21 | return tableReader.read(data); 22 | } 23 | 24 | public parseFile(data: string | DataTable[], namespace: string) { 25 | let fileTables: DataTable[]; 26 | if (typeof data === "string") { 27 | fileTables = this.readTables(data); 28 | } else { 29 | fileTables = data; 30 | } 31 | 32 | if (_.isEmpty(fileTables)) { 33 | return new TestSuite({ 34 | start: { line: 0, column: 0 }, 35 | end: { line: 0, column: 0 }, 36 | }); 37 | } 38 | 39 | const firstTable = _.first(fileTables); 40 | const lastTable = _.last(fileTables); 41 | 42 | const testDataFile = new TestSuite({ 43 | start: firstTable.location.start, 44 | end: lastTable.location.end, 45 | }); 46 | 47 | fileTables.forEach(dataTable => { 48 | const lowerCaseTableName = dataTable.name.toLowerCase(); 49 | 50 | if (SETTINGS_TABLES.has(lowerCaseTableName)) { 51 | const parsedTable = parseSettingsTable(dataTable); 52 | 53 | testDataFile.settingsTable = parsedTable; 54 | } else if (VARIABLES_TABLES.has(lowerCaseTableName)) { 55 | const parsedTable = parseVariablesTable(dataTable); 56 | 57 | testDataFile.variablesTable = parsedTable; 58 | } else if (KEYWORDS_TABLES.has(lowerCaseTableName)) { 59 | const parsedTable = parseKeywordsTable(dataTable, namespace); 60 | 61 | testDataFile.keywordsTable = parsedTable; 62 | } else if (TEST_CASES_TABLES.has(lowerCaseTableName)) { 63 | const parsedTable = parseTestCasesTable(dataTable); 64 | 65 | testDataFile.testCasesTable = parsedTable; 66 | } 67 | }); 68 | 69 | return testDataFile; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /server/src/parser/position-helper.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { Position, SourceLocation } from "./table-models"; 3 | 4 | export function position(line: number, column: number) { 5 | return { 6 | line, 7 | column, 8 | }; 9 | } 10 | 11 | export function location( 12 | startLine: number, 13 | startColumn: number, 14 | endLine: number, 15 | endColumn: number 16 | ): SourceLocation { 17 | return { 18 | start: position(startLine, startColumn), 19 | end: position(endLine, endColumn), 20 | }; 21 | } 22 | 23 | export function locationFromStartEnd( 24 | start: SourceLocation, 25 | end: SourceLocation 26 | ) { 27 | return { 28 | start: start.start, 29 | end: end.end, 30 | }; 31 | } 32 | 33 | export function locationFromPositions(start: Position, end: Position) { 34 | return { start, end }; 35 | } 36 | -------------------------------------------------------------------------------- /server/src/parser/row-iterator.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { DataTable, DataRow } from "./table-models"; 4 | 5 | export class TableRowIterator { 6 | private rowIdx: number = 0; 7 | 8 | constructor(private table: DataTable) {} 9 | 10 | public isDone(): boolean { 11 | return this.rowIdx >= this.table.rows.length; 12 | } 13 | 14 | public advance(): boolean { 15 | this.rowIdx++; 16 | 17 | return this.isDone(); 18 | } 19 | 20 | /** 21 | * Takes one row and advances the iterator 22 | */ 23 | public takeRow(): DataRow { 24 | return this.table.rows[this.rowIdx++]; 25 | } 26 | 27 | /** 28 | * Takes one row without advancing the iterator 29 | */ 30 | public peekRow(): DataRow { 31 | return this.table.rows[this.rowIdx]; 32 | } 33 | 34 | /** 35 | * Takes current row and as many rows until predicateFn 36 | * returns false for some row 37 | * 38 | * @param predicateFn 39 | */ 40 | public takeRowWhile(predicateFn: (row: DataRow) => boolean): DataRow[] { 41 | const rows = []; 42 | 43 | let row = this.peekRow(); 44 | while (row && predicateFn(row)) { 45 | this.rowIdx++; 46 | rows.push(row); 47 | 48 | row = this.peekRow(); 49 | } 50 | 51 | return rows; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/src/parser/settings-table-parser.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import * as positionHelper from "./position-helper"; 4 | import { TableRowIterator } from "./row-iterator"; 5 | import { DataCell, DataTable, DataRow } from "./table-models"; 6 | 7 | import { 8 | EmptyNode, 9 | SettingsTable, 10 | LibraryImport, 11 | ResourceImport, 12 | VariableImport, 13 | SuiteSetting, 14 | } from "./models"; 15 | 16 | import { 17 | parseIdentifier, 18 | parseValueExpression, 19 | parseCallExpression, 20 | } from "./primitive-parsers"; 21 | import * as settingsParser from "./setting-parser"; 22 | 23 | const settingParserMap = new Map([ 24 | ["Documentation", parseDocumentation], 25 | ["Library", parseLibraryImport], 26 | ["Resource", parseResourceImport], 27 | ["Variables", parseVariableImport], 28 | ["Suite Setup", createParseSettingFn("suiteSetup")], 29 | ["Suite Teardown", createParseSettingFn("suiteTeardown")], 30 | ["Test Setup", createParseSettingFn("testSetup")], 31 | ["Test Teardown", createParseSettingFn("testTeardown")], 32 | ]); 33 | 34 | /** 35 | * Parses given table as settings table 36 | */ 37 | export function parseSettingsTable(dataTable: DataTable): SettingsTable { 38 | const settingsTable = new SettingsTable(dataTable.location); 39 | 40 | const iterator = new TableRowIterator(dataTable); 41 | while (!iterator.isDone()) { 42 | const row = iterator.takeRow(); 43 | if (row.isEmpty()) { 44 | continue; 45 | } 46 | 47 | const continuedRows = iterator.takeRowWhile(rowContinues); 48 | const continuedCells = joinRows(continuedRows); 49 | const [firstCell, ...restCells] = row.cells.concat(continuedCells); 50 | 51 | const parseRowFn = getParserFn(firstCell); 52 | parseRowFn(settingsTable, firstCell, restCells); 53 | } 54 | 55 | return settingsTable; 56 | } 57 | 58 | function rowContinues(row: DataRow) { 59 | return row.isRowContinuation({ 60 | requireFirstEmpty: false, 61 | }); 62 | } 63 | 64 | function joinRows(rows: DataRow[]): DataCell[] { 65 | const shouldTakeCell = (cell: DataCell) => !cell.isRowContinuation(); 66 | 67 | return rows.reduce((allCells, row) => { 68 | const rowCells = _.takeRightWhile(row.cells, shouldTakeCell); 69 | 70 | return allCells.concat(rowCells); 71 | }, []); 72 | } 73 | 74 | function getParserFn(cell: DataCell) { 75 | const name = cell.content; 76 | 77 | const parser = settingParserMap.get(name); 78 | 79 | return parser || _.noop; 80 | } 81 | 82 | function parseDocumentation( 83 | settingsTable: SettingsTable, 84 | firstCell: DataCell, 85 | restCells: DataCell[] 86 | ) { 87 | const id = parseIdentifier(firstCell); 88 | const documentation = settingsParser.parseDocumentation(id, restCells); 89 | 90 | settingsTable.documentation = documentation; 91 | } 92 | 93 | function parseLibraryImport( 94 | settingsTable: SettingsTable, 95 | firstCell: DataCell, 96 | restCells: DataCell[] 97 | ) { 98 | const [firstDataCell, ...restDataCells] = restCells; 99 | const target = parseValueExpression(firstDataCell); 100 | const args = restDataCells.map(parseValueExpression); 101 | 102 | // TODO: WITH NAME keyword 103 | const lastCell = _.last(restCells) || firstCell; 104 | const location = positionHelper.locationFromStartEnd( 105 | firstCell.location, 106 | lastCell.location 107 | ); 108 | 109 | const libImport = new LibraryImport(target, args, location); 110 | settingsTable.addLibraryImport(libImport); 111 | } 112 | 113 | function parseResourceImport( 114 | settingsTable: SettingsTable, 115 | firstCell: DataCell, 116 | restCells: DataCell[] 117 | ) { 118 | const [firstDataCell] = restCells; 119 | const target = parseValueExpression(firstDataCell); 120 | 121 | const lastCell = _.last(restCells) || firstCell; 122 | const location = positionHelper.locationFromStartEnd( 123 | firstCell.location, 124 | lastCell.location 125 | ); 126 | 127 | const resourceImport = new ResourceImport(target, location); 128 | settingsTable.addResourceImport(resourceImport); 129 | } 130 | 131 | function parseVariableImport( 132 | settingsTable: SettingsTable, 133 | firstCell: DataCell, 134 | restCells: DataCell[] 135 | ) { 136 | const [firstDataCell] = restCells; 137 | const target = parseValueExpression(firstDataCell); 138 | 139 | const lastCell = _.last(restCells) || firstCell; 140 | const location = positionHelper.locationFromStartEnd( 141 | firstCell.location, 142 | lastCell.location 143 | ); 144 | 145 | const variableImport = new VariableImport(target, location); 146 | settingsTable.addVariableImport(variableImport); 147 | } 148 | 149 | function createParseSettingFn( 150 | propertyName: "suiteSetup" | "suiteTeardown" | "testSetup" | "testTeardown" 151 | ) { 152 | return ( 153 | settingsTable: SettingsTable, 154 | nameCell: DataCell, 155 | valueCells: DataCell[] 156 | ) => { 157 | const name = parseIdentifier(nameCell); 158 | 159 | const value = _.isEmpty(valueCells) 160 | ? new EmptyNode(nameCell.location.end) 161 | : parseCallExpression(valueCells); 162 | 163 | const lastCell = _.last(valueCells) || nameCell; 164 | const location = positionHelper.locationFromStartEnd( 165 | nameCell.location, 166 | lastCell.location 167 | ); 168 | const setting = new SuiteSetting(name, value, location); 169 | settingsTable[propertyName] = setting; 170 | }; 171 | } 172 | -------------------------------------------------------------------------------- /server/src/parser/table-models.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | /** 4 | * A position in a text 5 | */ 6 | export interface Position { 7 | line: number; 8 | column: number; 9 | } 10 | 11 | /** 12 | * A range with start and end position in a text 13 | */ 14 | export interface SourceLocation { 15 | start: Position; 16 | end: Position; 17 | } 18 | 19 | /** 20 | * Represents a table with rows 21 | */ 22 | export class DataTable { 23 | public rows: DataRow[] = []; 24 | 25 | /** 26 | * 27 | */ 28 | constructor( 29 | public name: string, 30 | public header: DataRow 31 | ) {} 32 | 33 | public get location(): SourceLocation { 34 | if (_.isEmpty(this.rows)) { 35 | return this.header.location; 36 | } 37 | 38 | const lastRow = _.last(this.rows); 39 | 40 | return { 41 | start: this.header.location.start, 42 | end: lastRow.location.end, 43 | }; 44 | } 45 | 46 | public addRow(row: DataRow) { 47 | this.rows.push(row); 48 | } 49 | } 50 | 51 | /** 52 | * Represents a row with zero or more cells 53 | */ 54 | export class DataRow { 55 | public cells: DataCell[] = []; 56 | 57 | constructor(public location: SourceLocation) {} 58 | 59 | /** 60 | * Returns the first cell 61 | */ 62 | public first() { 63 | return _.first(this.cells); 64 | } 65 | 66 | /** 67 | * Returns the last cell 68 | */ 69 | public last() { 70 | return _.last(this.cells); 71 | } 72 | 73 | /** 74 | * Returns the (0 based) index of given cell or -1 if not found 75 | * 76 | * @param cell Cell to find 77 | */ 78 | public indexOf(cell: DataCell) { 79 | return this.cells.indexOf(cell); 80 | } 81 | 82 | /** 83 | * Is the row empty. Row is empty if all its cells are empty 84 | */ 85 | public isEmpty() { 86 | return _.every(this.cells, cell => cell.isEmpty()); 87 | } 88 | 89 | /** 90 | * Tests if row begins with empty cells and ... 91 | * 92 | * @param requireFirstEmpty - Is at least one empty cell required 93 | */ 94 | public isRowContinuation({ requireFirstEmpty = false } = {}) { 95 | if (requireFirstEmpty && !this.first().isEmpty()) { 96 | return false; 97 | } 98 | 99 | for (const cell of this.cells) { 100 | if (cell.isRowContinuation()) { 101 | return true; 102 | } else if (!cell.isEmpty()) { 103 | return false; 104 | } 105 | } 106 | 107 | return false; 108 | } 109 | 110 | /** 111 | * Returns the cell with given index 112 | */ 113 | public getCellByIdx(index: number): DataCell { 114 | return this.cells[index]; 115 | } 116 | 117 | /** 118 | * Returns a range of cells 119 | */ 120 | public getCellsByRange(startIdx: number, endIdx?: number): DataCell[] { 121 | return this.cells.slice(startIdx, endIdx); 122 | } 123 | 124 | /** 125 | * Add a cell 126 | */ 127 | public addCell(cell: DataCell) { 128 | this.cells.push(cell); 129 | } 130 | } 131 | 132 | /** 133 | * Represents a single cell in a table 134 | */ 135 | export class DataCell { 136 | /** 137 | * 138 | */ 139 | constructor( 140 | public content: string, 141 | public location: SourceLocation 142 | ) {} 143 | 144 | /** 145 | * Is the cell empty. Cell is empty if it's only whitespace or 146 | * only a single backslash 147 | */ 148 | public isEmpty() { 149 | return /^(\s*|\\)$/.test(this.content); 150 | } 151 | 152 | public isRowContinuation() { 153 | return /^\.\.\.$/.test(this.content); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /server/src/parser/table-reader.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { DataTable, DataRow, DataCell } from "./table-models"; 4 | 5 | /** 6 | * Parses a string of text into data tables 7 | */ 8 | export class TableReader { 9 | private lineReader: LineReader = new LineReader(); 10 | 11 | /** 12 | * 13 | */ 14 | public read(data: string) { 15 | const readTables: DataTable[] = []; 16 | const lines = data.split(/\r\n|\n|\r/); 17 | let currentTable: DataTable = null; 18 | 19 | lines.forEach((line, index) => { 20 | const row = this.lineReader.readLine(index, line); 21 | 22 | if (this.startsTable(row)) { 23 | const name = this.readTableName(row); 24 | currentTable = new DataTable(name, row); 25 | 26 | readTables.push(currentTable); 27 | } else if (currentTable) { 28 | currentTable.addRow(row); 29 | } 30 | }); 31 | 32 | return readTables; 33 | } 34 | 35 | private startsTable(row: DataRow) { 36 | return row.first().content.trim().startsWith("*"); 37 | } 38 | 39 | private readTableName(row: DataRow) { 40 | return row.first().content.replace(/\*/g, "").trim(); 41 | } 42 | } 43 | 44 | /** 45 | * Parses a line into a row with cells 46 | */ 47 | class LineReader { 48 | private position: number = 0; 49 | private line: string; 50 | private lineNumber: number; 51 | 52 | public readLine(lineNumber: number, line: string) { 53 | this.lineNumber = lineNumber; 54 | this.line = this.trimComments(line); 55 | this.position = 0; 56 | 57 | const row = new DataRow({ 58 | start: { 59 | line: lineNumber, 60 | column: 0, 61 | }, 62 | end: { 63 | line: lineNumber, 64 | column: this.line.length, 65 | }, 66 | }); 67 | 68 | do { 69 | const cell = this.readCell(); 70 | 71 | row.addCell(cell); 72 | 73 | this.readWhitespace(); 74 | } while (!this.isEnd()); 75 | 76 | return row; 77 | } 78 | 79 | private trimComments(line: string) { 80 | const startOfCommentIdx = this.findStartOfCommentIdx(line); 81 | 82 | if (startOfCommentIdx > -1) { 83 | return line.substring(0, startOfCommentIdx); 84 | } else { 85 | return line; 86 | } 87 | } 88 | 89 | private findStartOfCommentIdx(line: string) { 90 | let possibleStartIdx = line.indexOf("#", 0); 91 | 92 | while (possibleStartIdx > -1) { 93 | // Escaped number sign doesn't start a comment 94 | if (line.charAt(possibleStartIdx - 1) !== "\\") { 95 | return possibleStartIdx; 96 | } 97 | 98 | possibleStartIdx = line.indexOf("#", possibleStartIdx + 1); 99 | } 100 | 101 | return -1; 102 | } 103 | 104 | private endOfCellIdx() { 105 | const cellSeparators = [" ", " \t", "\t"]; 106 | 107 | const separatorIndexes = cellSeparators 108 | .map(sep => this.line.indexOf(sep, this.position)) 109 | .filter(index => index !== -1); 110 | const smallestIdx = _.min(separatorIndexes); 111 | 112 | return smallestIdx !== undefined ? smallestIdx : this.line.length; 113 | } 114 | 115 | /** 116 | * Reads a cell starting from current position and 117 | * advances the position. 118 | */ 119 | private readCell() { 120 | const endOfCellIdx = this.endOfCellIdx(); 121 | 122 | const cellContent = this.line.substring(this.position, endOfCellIdx); 123 | const cell = new DataCell(cellContent, { 124 | start: { 125 | line: this.lineNumber, 126 | column: this.position, 127 | }, 128 | end: { 129 | line: this.lineNumber, 130 | column: endOfCellIdx, 131 | }, 132 | }); 133 | 134 | this.position = endOfCellIdx; 135 | 136 | return cell; 137 | } 138 | 139 | private readWhitespace() { 140 | while (!this.isEnd() && /\s/.test(this.line.charAt(this.position))) { 141 | this.position = this.position + 1; 142 | } 143 | } 144 | 145 | private isEnd() { 146 | return this.position >= this.line.length; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /server/src/parser/test-cases-table-parser.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { TableRowIterator } from "./row-iterator"; 4 | import { DataTable, DataRow, DataCell } from "./table-models"; 5 | 6 | import { TestCasesTable, TestCase, SettingDeclaration } from "./models"; 7 | 8 | import * as SettingParser from "./setting-parser"; 9 | 10 | import { parseIdentifier } from "./primitive-parsers"; 11 | 12 | import { parseStep } from "./function-parsers"; 13 | 14 | const testCaseSettings = new Set([ 15 | "[Documentation]", 16 | "[Teardown]", 17 | "[Tags]", 18 | "[Timeout]", 19 | "[Setup]", 20 | // TODO: Not implemented yet "[Template]" 21 | ]); 22 | 23 | export function parseTestCasesTable(dataTable: DataTable): TestCasesTable { 24 | const testCasesTable = new TestCasesTable(dataTable.location); 25 | let currentTestCase: TestCase; 26 | 27 | const iterator = new TableRowIterator(dataTable); 28 | while (!iterator.isDone()) { 29 | const row = iterator.takeRow(); 30 | if (row.isEmpty()) { 31 | continue; 32 | } 33 | 34 | if (startsTestCase(row)) { 35 | const identifier = parseIdentifier(row.first()); 36 | 37 | currentTestCase = new TestCase(identifier, row.location.start); 38 | testCasesTable.addTestCase(currentTestCase); 39 | } else if (currentTestCase) { 40 | const firstRowDataCells = row.getCellsByRange(1); 41 | const continuedRows = iterator.takeRowWhile(rowContinues); 42 | const continuedCells = joinRows(continuedRows); 43 | const [firstCell, ...restCells] = 44 | firstRowDataCells.concat(continuedCells); 45 | 46 | if (testCaseSettings.has(firstCell.content)) { 47 | const setting = SettingParser.parseSetting(firstCell, restCells); 48 | 49 | setTestCaseSetting(currentTestCase, setting); 50 | currentTestCase.location.end = setting.location.end; 51 | } else { 52 | const step = parseStep(firstCell, restCells); 53 | currentTestCase.addStep(step); 54 | currentTestCase.location.end = step.location.end; 55 | } 56 | } 57 | } 58 | 59 | return testCasesTable; 60 | } 61 | 62 | function rowContinues(row: DataRow) { 63 | return row.isRowContinuation({ 64 | requireFirstEmpty: true, 65 | }); 66 | } 67 | 68 | function joinRows(rows: DataRow[]): DataCell[] { 69 | const shouldTakeCell = (cell: DataCell) => !cell.isRowContinuation(); 70 | 71 | return rows.reduce((allCells, row) => { 72 | const rowCells = _.takeRightWhile(row.cells, shouldTakeCell); 73 | 74 | return allCells.concat(rowCells); 75 | }, []); 76 | } 77 | 78 | function setTestCaseSetting(testCase: TestCase, setting: SettingDeclaration) { 79 | if (SettingParser.isDocumentation(setting)) { 80 | testCase.documentation = setting; 81 | } else if (SettingParser.isTimeout(setting)) { 82 | testCase.timeout = setting; 83 | } else if (SettingParser.isSetup(setting)) { 84 | testCase.setup = setting; 85 | } else if (SettingParser.isTeardown(setting)) { 86 | testCase.teardown = setting; 87 | } else if (SettingParser.isTags(setting)) { 88 | testCase.tags = setting; 89 | } 90 | } 91 | 92 | function startsTestCase(row: DataRow) { 93 | return !row.first().isEmpty(); 94 | } 95 | -------------------------------------------------------------------------------- /server/src/parser/test/function-parsers.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { parseStep } from "../function-parsers"; 5 | import { DataCell } from "../table-models"; 6 | 7 | import { Identifier, ScalarDeclaration, Step } from "../models"; 8 | 9 | import { createLocation } from "./test-helper"; 10 | 11 | function parseAndAssert(data: DataCell[], expected: Step) { 12 | const [first, ...rest] = data; 13 | const actual = parseStep(first, rest); 14 | 15 | chai.assert.deepEqual(actual, expected); 16 | } 17 | 18 | describe("parseStep", () => { 19 | it("should parse empty variable declaration", () => { 20 | const loc = createLocation(0, 0, 0, 7); 21 | const data = [new DataCell("${var}=", loc)]; 22 | 23 | const expected = new Step( 24 | new ScalarDeclaration(new Identifier("var", loc), null, loc), 25 | loc 26 | ); 27 | 28 | parseAndAssert(data, expected); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /server/src/parser/test/table-reader.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { TableReader } from "../table-reader"; 5 | 6 | import { DataCell, DataTable } from "../table-models"; 7 | 8 | import { createLocation, table, row } from "./test-helper"; 9 | 10 | const reader = new TableReader(); 11 | 12 | function header(text: string) { 13 | return row(createLocation(0, 0, 0, text.length), [ 14 | new DataCell(text, createLocation(0, 0, 0, text.length)), 15 | ]); 16 | } 17 | 18 | describe("TableReader", () => { 19 | it("should recognise table name", () => { 20 | const name = "Table Name"; 21 | 22 | const shouldReadName = (tableString: string) => { 23 | const [actual] = reader.read(tableString); 24 | 25 | chai.assert.equal(actual.name, name); 26 | }; 27 | 28 | shouldReadName(`***${name}***`); 29 | shouldReadName(`*** ${name} ***`); 30 | shouldReadName(`***${name}`); 31 | shouldReadName(`*** ${name}`); 32 | shouldReadName(`*${name}`); 33 | shouldReadName(`* ${name}`); 34 | }); 35 | 36 | it("should read empty table", () => { 37 | const data = `*** Table`; 38 | 39 | const actual = reader.read(data); 40 | const expected = [ 41 | table("Table", { 42 | header: header(`*** Table`), 43 | }), 44 | ]; 45 | 46 | chai.assert.deepEqual(actual, expected); 47 | }); 48 | 49 | it("should parse empty first cell", () => { 50 | const data = `*Table\n cell1`; 51 | 52 | const actual = reader.read(data); 53 | 54 | const expected = [ 55 | table("Table", { 56 | header: header("*Table"), 57 | rows: [ 58 | row(createLocation(1, 0, 1, 9), [ 59 | new DataCell("", createLocation(1, 0, 1, 0)), 60 | new DataCell("cell1", createLocation(1, 4, 1, 9)), 61 | ]), 62 | ], 63 | }), 64 | ]; 65 | 66 | chai.assert.deepEqual(actual, expected); 67 | }); 68 | 69 | it("should read single table", () => { 70 | const data = `*** Table\ncell1 cell2`; 71 | 72 | const actual = reader.read(data); 73 | 74 | const expected = [ 75 | table("Table", { 76 | header: header("*** Table"), 77 | rows: [ 78 | row(createLocation(1, 0, 1, 14), [ 79 | new DataCell("cell1", createLocation(1, 0, 1, 5)), 80 | new DataCell("cell2", createLocation(1, 9, 1, 14)), 81 | ]), 82 | ], 83 | }), 84 | ]; 85 | 86 | chai.assert.deepEqual(actual, expected); 87 | }); 88 | 89 | it("should ignore trailing whitespace", () => { 90 | const data = `*** Table\ncell1 `; 91 | 92 | const actual = reader.read(data); 93 | 94 | const expected = [ 95 | table("Table", { 96 | header: header("*** Table"), 97 | rows: [ 98 | row(createLocation(1, 0, 1, 9), [ 99 | new DataCell("cell1", createLocation(1, 0, 1, 5)), 100 | ]), 101 | ], 102 | }), 103 | ]; 104 | 105 | chai.assert.deepEqual(actual, expected); 106 | }); 107 | 108 | it("should skip comments", () => { 109 | const data = `*** Table # Inline comment\n#Comment line\ncell1 cell2`; 110 | 111 | const actual = reader.read(data); 112 | 113 | const expected = [ 114 | table("Table", { 115 | header: header("*** Table "), 116 | rows: [ 117 | row(createLocation(1, 0, 1, 0), [ 118 | new DataCell("", createLocation(1, 0, 1, 0)), 119 | ]), 120 | row(createLocation(2, 0, 2, 14), [ 121 | new DataCell("cell1", createLocation(2, 0, 2, 5)), 122 | new DataCell("cell2", createLocation(2, 9, 2, 14)), 123 | ]), 124 | ], 125 | }), 126 | ]; 127 | 128 | chai.assert.deepEqual(actual, expected); 129 | }); 130 | 131 | it("should ignore lines outside table", () => { 132 | const data = `Not in a table\nAnother outside table`; 133 | 134 | const actual = reader.read(data); 135 | const expected: DataTable[] = []; 136 | 137 | chai.assert.deepEqual(actual, expected); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /server/src/parser/test/test-cases-table-parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { FileParser } from "../parser"; 5 | const parser = new FileParser(); 6 | 7 | import { 8 | Identifier, 9 | NamespacedIdentifier, 10 | CallExpression, 11 | Literal, 12 | TestCasesTable, 13 | VariableExpression, 14 | TestCase, 15 | Step, 16 | } from "../models"; 17 | 18 | import { createLocation } from "./test-helper"; 19 | import { SourceLocation } from "../table-models"; 20 | 21 | const NAMESPACE = ""; 22 | 23 | function parseAndAssert(tableDefinition: string, expected: TestCasesTable) { 24 | const actual = parser.parseFile(tableDefinition, NAMESPACE).testCasesTable; 25 | 26 | chai.assert.deepEqual(actual, expected); 27 | } 28 | 29 | function testCasesTable(location: SourceLocation, testCases: TestCase[]) { 30 | return Object.assign(new TestCasesTable(location), { testCases }); 31 | } 32 | 33 | function testCase( 34 | location: SourceLocation, 35 | name: Identifier, 36 | steps: Step[], 37 | settings: any = {} 38 | ) { 39 | return Object.assign( 40 | new TestCase(name, location.start), 41 | { location }, 42 | { steps }, 43 | settings 44 | ); 45 | } 46 | 47 | describe("Parsing Test Cases table", () => { 48 | it("should skip invalid data", () => { 49 | const data = `*** Test Cases *** 50 | not a test case cell2 51 | !another invalid data 52 | `; 53 | 54 | const expected = testCasesTable(createLocation(0, 0, 3, 0), []); 55 | 56 | parseAndAssert(data, expected); 57 | }); 58 | 59 | it("should parse steps", () => { 60 | const data = `*** Test Cases *** 61 | TestCas Name 62 | Step 1 arg1 arg2 63 | Step 2 \${VAR} a longer arg2 64 | `; 65 | 66 | const expected = testCasesTable(createLocation(0, 0, 4, 0), [ 67 | testCase( 68 | createLocation(1, 0, 3, 37), 69 | new Identifier("TestCas Name", createLocation(1, 0, 1, 12)), 70 | [ 71 | new Step( 72 | new CallExpression( 73 | new Identifier("Step 1", createLocation(2, 4, 2, 10)), 74 | [ 75 | new Literal("arg1", createLocation(2, 14, 2, 18)), 76 | new Literal("arg2", createLocation(2, 24, 2, 28)), 77 | ], 78 | createLocation(2, 4, 2, 28) 79 | ), 80 | createLocation(2, 4, 2, 28) 81 | ), 82 | new Step( 83 | new CallExpression( 84 | new Identifier("Step 2", createLocation(3, 4, 3, 10)), 85 | [ 86 | new VariableExpression( 87 | new Identifier("VAR", createLocation(3, 16, 3, 19)), 88 | "Scalar", 89 | createLocation(3, 14, 3, 20) 90 | ), 91 | new Literal("a longer arg2", createLocation(3, 24, 3, 37)), 92 | ], 93 | createLocation(3, 4, 3, 37) 94 | ), 95 | createLocation(3, 4, 3, 37) 96 | ), 97 | ] 98 | ), 99 | ]); 100 | 101 | parseAndAssert(data, expected); 102 | }); 103 | 104 | it("should parse step from multiple lines", () => { 105 | const data = `*** Test Cases *** 106 | TestCas Name 107 | Step 1 arg1 108 | ... arg2 109 | `; 110 | 111 | const expected = testCasesTable(createLocation(0, 0, 4, 0), [ 112 | testCase( 113 | createLocation(1, 0, 3, 18), 114 | new Identifier("TestCas Name", createLocation(1, 0, 1, 12)), 115 | [ 116 | new Step( 117 | new CallExpression( 118 | new Identifier("Step 1", createLocation(2, 4, 2, 10)), 119 | [ 120 | new Literal("arg1", createLocation(2, 14, 2, 18)), 121 | new Literal("arg2", createLocation(3, 14, 3, 18)), 122 | ], 123 | createLocation(2, 4, 3, 18) 124 | ), 125 | createLocation(2, 4, 3, 18) 126 | ), 127 | ] 128 | ), 129 | ]); 130 | 131 | parseAndAssert(data, expected); 132 | }); 133 | 134 | it("should parse steps with explicit keywords", () => { 135 | const data = `*** Test Cases *** 136 | TestCas Name 137 | MyLibrary.Step 1 arg1 arg2 138 | Deep.Library.Step 1 \${VAR} a longer arg2 139 | `; 140 | 141 | const expected = testCasesTable(createLocation(0, 0, 4, 0), [ 142 | testCase( 143 | createLocation(1, 0, 3, 50), 144 | new Identifier("TestCas Name", createLocation(1, 0, 1, 12)), 145 | [ 146 | new Step( 147 | new CallExpression( 148 | new NamespacedIdentifier( 149 | "MyLibrary", 150 | "Step 1", 151 | createLocation(2, 4, 2, 20) 152 | ), 153 | [ 154 | new Literal("arg1", createLocation(2, 24, 2, 28)), 155 | new Literal("arg2", createLocation(2, 34, 2, 38)), 156 | ], 157 | createLocation(2, 4, 2, 38) 158 | ), 159 | createLocation(2, 4, 2, 38) 160 | ), 161 | new Step( 162 | new CallExpression( 163 | new NamespacedIdentifier( 164 | "Deep.Library", 165 | "Step 1", 166 | createLocation(3, 4, 3, 23) 167 | ), 168 | [ 169 | new VariableExpression( 170 | new Identifier("VAR", createLocation(3, 29, 3, 32)), 171 | "Scalar", 172 | createLocation(3, 27, 3, 33) 173 | ), 174 | new Literal("a longer arg2", createLocation(3, 37, 3, 50)), 175 | ], 176 | createLocation(3, 4, 3, 50) 177 | ), 178 | createLocation(3, 4, 3, 50) 179 | ), 180 | ] 181 | ), 182 | ]); 183 | 184 | parseAndAssert(data, expected); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /server/src/parser/test/test-helper.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { SourceLocation, DataTable, DataRow, DataCell } from "../table-models"; 4 | export { 5 | position as createPosition, 6 | location as createLocation, 7 | } from "../position-helper"; 8 | 9 | // export const position = createPosition; 10 | 11 | // export const createLocation = location; 12 | 13 | export function table( 14 | name: string, 15 | content: { header: DataRow; rows?: DataRow[] } 16 | ) { 17 | const theTable = new DataTable(name, content.header); 18 | 19 | return content.rows 20 | ? Object.assign(theTable, { rows: content.rows }) 21 | : theTable; 22 | } 23 | 24 | export function row(location: SourceLocation, cells?: DataCell[]): DataRow { 25 | const theRow = new DataRow(location); 26 | 27 | return cells ? Object.assign(theRow, { cells }) : theRow; 28 | } 29 | 30 | export function createCell(location: SourceLocation, content: string) { 31 | return new DataCell(content, location); 32 | } 33 | -------------------------------------------------------------------------------- /server/src/parser/test/variable-parsers.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { isVariable, parseTypeAndName } from "../variable-parsers"; 5 | import { DataCell } from "../table-models"; 6 | 7 | import { Identifier } from "../models"; 8 | 9 | import { createLocation } from "./test-helper"; 10 | 11 | const DUMMY_LOC = createLocation(0, 0, 0, 0); 12 | 13 | function assertIsVariable(cellData: string) { 14 | const cell = new DataCell(cellData, DUMMY_LOC); 15 | const actual = isVariable(cell); 16 | 17 | chai.assert.isTrue(actual); 18 | } 19 | 20 | function assertParseResult( 21 | cellData: string, 22 | expectedType: string, 23 | expectedName: string 24 | ) { 25 | const cell = new DataCell(cellData, DUMMY_LOC); 26 | const expected = { 27 | type: expectedType, 28 | name: new Identifier(expectedName, DUMMY_LOC), 29 | }; 30 | 31 | const actual = parseTypeAndName(cell); 32 | 33 | chai.assert.deepEqual(actual, expected); 34 | } 35 | 36 | describe("Variable parsing", () => { 37 | describe("isVariable", () => { 38 | it("should recognize an empty scalar", () => { 39 | assertIsVariable("${}"); 40 | }); 41 | 42 | it("should recognize an empty list", () => { 43 | assertIsVariable("${}"); 44 | }); 45 | 46 | it("should recognize scalar", () => { 47 | assertIsVariable("${var}"); 48 | }); 49 | 50 | it("should recognize scalar", () => { 51 | assertIsVariable("${var}"); 52 | }); 53 | }); 54 | 55 | describe("parseTypeAndName", () => { 56 | it("should parse empty scalar", () => { 57 | assertParseResult("${}", "$", ""); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /server/src/parser/test/variables-table-parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { parseVariablesTable } from "../variables-table-parser"; 5 | import { VariablesTable, VariableDeclaration } from "../models"; 6 | 7 | import { createLocation, table, row, createCell } from "./test-helper"; 8 | import { SourceLocation, DataTable } from "../table-models"; 9 | 10 | function parseAndAssert(tableDefinition: DataTable, expected: VariablesTable) { 11 | const actual = parseVariablesTable(tableDefinition); 12 | 13 | chai.assert.deepEqual(actual, expected); 14 | } 15 | 16 | function variablesTable( 17 | location: SourceLocation, 18 | variables: VariableDeclaration[] 19 | ) { 20 | return Object.assign(new VariablesTable(location), { variables }); 21 | } 22 | 23 | describe("Parsing Variables table", () => { 24 | it("should skip invalid data", () => { 25 | const tableDefinition = table("Variables", { 26 | header: row(createLocation(0, 0, 0, 10)), 27 | rows: [ 28 | row(createLocation(1, 0, 1, 10), [ 29 | createCell(createLocation(1, 0, 1, 10), "not a variable"), 30 | createCell(createLocation(1, 0, 1, 10), "cell2"), 31 | ]), 32 | row(createLocation(2, 0, 2, 10), [ 33 | createCell(createLocation(2, 0, 2, 10), "!another invalid"), 34 | createCell(createLocation(2, 0, 2, 10), "data"), 35 | ]), 36 | ], 37 | }); 38 | 39 | const expected = variablesTable(createLocation(0, 0, 2, 10), []); 40 | 41 | parseAndAssert(tableDefinition, expected); 42 | }); 43 | 44 | // it("should parse scalar variables", () => { 45 | // const tableDefinition = table("Variables", { 46 | // header: row(location(0, 0, 0, 10)), 47 | // rows: [ 48 | // row(location(1, 0, 1, 10), [ 49 | // cell(location(1, 0, 1, 10), "${var1}"), 50 | // cell(location(1, 0, 1, 10), "value") 51 | // ]), 52 | // row(location(2, 0, 2, 10), [ 53 | // cell(location(2, 0, 2, 10), "${var2}"), 54 | // cell(location(2, 0, 2, 10), "More complex ${variable}") 55 | // ]), 56 | // ] 57 | // }); 58 | 59 | // const expected = variablesTable(location(0, 0, 2, 10), [ 60 | // new ScalarDeclaration("var1", "value", location(1, 0, 1, 10)), 61 | // new ScalarDeclaration("var2", "More complex ${variable}", location(2, 0, 2, 10)) 62 | // ]); 63 | 64 | // parseAndAssert(tableDefinition, expected); 65 | // }); 66 | 67 | it("should parse list variables", () => { 68 | // TODO 69 | }); 70 | 71 | it("should parse dictionary variables", () => { 72 | // TODO 73 | }); 74 | 75 | it("should parse environment variables", () => { 76 | // TODO 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /server/src/parser/variable-parsers.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { DataCell, SourceLocation } from "./table-models"; 4 | 5 | import { 6 | Identifier, 7 | Expression, 8 | VariableDeclaration, 9 | ScalarDeclaration, 10 | ListDeclaration, 11 | DictionaryDeclaration, 12 | } from "./models"; 13 | 14 | const variableMapping = new Map([ 15 | ["$", parseScalar], 16 | ["@", parseList], 17 | ["&", parseDictionary], 18 | ["%", parseEnvironment], 19 | ]); 20 | 21 | function getRegex() { 22 | // Matches the type ($, @, % or &) and name 23 | // For example: 24 | // ${var} --> ["${var}", "$", "var"] 25 | // @{var2} = --> ["${var2}", "@", "var2"] 26 | return /^([$,@,%,&]){([^}]*)}/; 27 | } 28 | 29 | export function isVariable(cell: DataCell) { 30 | const typeAndNameRegex = getRegex(); 31 | const possibleTypeAndName = cell.content; 32 | 33 | return typeAndNameRegex.test(possibleTypeAndName); 34 | } 35 | 36 | export function parseTypeAndName(cell: DataCell) { 37 | const typeAndNameRegex = getRegex(); 38 | const typeAndName = cell.content; 39 | 40 | const result = typeAndName.match(typeAndNameRegex); 41 | return { 42 | type: result[1], 43 | name: new Identifier(result[2], cell.location), 44 | }; 45 | } 46 | 47 | export function parseVariableDeclaration( 48 | typeAndName: { 49 | type: string; 50 | name: Identifier; 51 | }, 52 | values: Expression[], 53 | location: SourceLocation 54 | ): VariableDeclaration { 55 | const { type, name } = typeAndName; 56 | 57 | const variableParserFn = getVariableParserFn(type); 58 | if (!variableParserFn) { 59 | throw new Error(`Invalid variable type ${type}`); 60 | } 61 | 62 | return variableParserFn(name, values, location); 63 | } 64 | 65 | function getVariableParserFn(type: string): Function { 66 | const parser = variableMapping.get(type); 67 | 68 | return parser; 69 | } 70 | 71 | function parseScalar( 72 | name: Identifier, 73 | values: Expression[], 74 | location: SourceLocation 75 | ): VariableDeclaration { 76 | const value = _.first(values); 77 | 78 | return new ScalarDeclaration(name, value, location); 79 | } 80 | 81 | function parseList( 82 | name: Identifier, 83 | values: Expression[], 84 | location: SourceLocation 85 | ): VariableDeclaration { 86 | return new ListDeclaration(name, values, location); 87 | } 88 | 89 | function parseDictionary( 90 | name: Identifier, 91 | values: Expression[], 92 | location: SourceLocation 93 | ): VariableDeclaration { 94 | // TODO 95 | return new DictionaryDeclaration(name, null, location); 96 | } 97 | 98 | function parseEnvironment( 99 | name: Identifier, 100 | values: Expression[], 101 | location: SourceLocation 102 | ): VariableDeclaration { 103 | // TODO 104 | return undefined; 105 | } 106 | -------------------------------------------------------------------------------- /server/src/parser/variables-table-parser.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { DataTable, DataRow } from "./table-models"; 3 | import { VariablesTable } from "./models"; 4 | import { parseValueExpression } from "./primitive-parsers"; 5 | import { 6 | isVariable, 7 | parseTypeAndName, 8 | parseVariableDeclaration, 9 | } from "./variable-parsers"; 10 | 11 | export function parseVariablesTable(dataTable: DataTable): VariablesTable { 12 | const variablesTable = new VariablesTable(dataTable.location); 13 | 14 | dataTable.rows.forEach(row => parseRow(variablesTable, row)); 15 | 16 | return variablesTable; 17 | } 18 | 19 | function parseRow(variablesTable: VariablesTable, row: DataRow) { 20 | if (row.isEmpty()) { 21 | return; 22 | } 23 | 24 | const typeNameCell = row.first(); 25 | if (!isVariable(typeNameCell)) { 26 | return; 27 | } 28 | 29 | const typeAndName = parseTypeAndName(typeNameCell); 30 | const values = row.getCellsByRange(1).map(parseValueExpression); 31 | const variableDeclaration = parseVariableDeclaration( 32 | typeAndName, 33 | values, 34 | row.location 35 | ); 36 | variablesTable.addVariable(variableDeclaration); 37 | } 38 | -------------------------------------------------------------------------------- /server/src/python-parser/python-parser.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { 4 | Identifier, 5 | Literal, 6 | Arguments, 7 | ScalarDeclaration, 8 | UserKeyword, 9 | KeywordsTable, 10 | TestSuite, 11 | NamespacedIdentifier, 12 | } from "../parser/models"; 13 | import { Range } from "../utils/position"; 14 | 15 | /** 16 | * Parser for python files 17 | */ 18 | export class PythonParser { 19 | /** 20 | * Parses function as keywords from given python file contents 21 | * 22 | * @param data python file contents 23 | * @param filePath 24 | */ 25 | public parseFile(data: string, namespace: string): TestSuite { 26 | const lineIndexes = getLineIndexes(data); 27 | const suiteRange = createRange(_.first(lineIndexes), _.last(lineIndexes)); 28 | 29 | const keywords = findKeywords(namespace, data, lineIndexes); 30 | 31 | return Object.assign(new TestSuite(suiteRange), { 32 | keywordsTable: Object.assign(new KeywordsTable(suiteRange), { 33 | keywords, 34 | }), 35 | }); 36 | } 37 | } 38 | 39 | const REQUIRED_WS = "(?:\\s+)"; 40 | const OPTIONAL_WS = "(?:\\s*)"; 41 | const FUNCTION_NAME = "(\\S+?)"; 42 | const ARGS = "(.*?)"; 43 | const FUNCTION_DECLARATION_REGEX = 44 | "(?:^|\\s)" + 45 | "def" + 46 | REQUIRED_WS + 47 | FUNCTION_NAME + 48 | OPTIONAL_WS + 49 | "\\(" + 50 | ARGS + 51 | "\\):"; 52 | 53 | function isCommentedOut(data: string, startIdx: number) { 54 | const earlierLineBreak = data.lastIndexOf("\n", startIdx); 55 | const earlierComment = data.lastIndexOf("#", startIdx); 56 | 57 | return earlierComment > earlierLineBreak; 58 | } 59 | 60 | interface LineInfo { 61 | line: number; 62 | start: number; 63 | end: number; 64 | } 65 | 66 | /** 67 | * Returns the start and end index of each line 68 | * 69 | * @param data 70 | */ 71 | function getLineIndexes(data: string): LineInfo[] { 72 | const lines = []; 73 | const regex = /\n|\r\n/g; 74 | let lineNumber = 0; 75 | let lastIndex = 0; 76 | 77 | findMatches( 78 | () => regex.exec(data), 79 | (result: RegExpExecArray) => { 80 | lines.push({ 81 | line: lineNumber++, 82 | start: lastIndex, 83 | end: result.index, 84 | }); 85 | 86 | lastIndex = result.index + result[0].length; 87 | } 88 | ); 89 | 90 | lines.push({ 91 | line: lineNumber++, 92 | start: lastIndex, 93 | end: data.length, 94 | }); 95 | 96 | return lines; 97 | } 98 | 99 | /** 100 | * Calls 'getMatchFn' to get a result and calls 'cb' with 101 | * the result until 'getMatchFn' returns null 102 | * 103 | * @param getMatchFn 104 | * @param cb 105 | */ 106 | function findMatches(getMatchFn: Function, cb: Function) { 107 | let result = getMatchFn(); 108 | 109 | while (result !== null) { 110 | cb(result); 111 | 112 | result = getMatchFn(); 113 | } 114 | } 115 | 116 | function createPosition(line: number, column: number) { 117 | return { 118 | line, 119 | column, 120 | }; 121 | } 122 | 123 | function createRange(startLine: LineInfo, endLine: LineInfo) { 124 | return { 125 | start: createPosition(startLine.line, startLine.start), 126 | end: createPosition(endLine.line, endLine.end - endLine.start), 127 | }; 128 | } 129 | 130 | function findRange(lineIndexes: LineInfo[], startIdx: number, endIdx: number) { 131 | let start; 132 | 133 | const isIndexOnLine = (line: LineInfo, idx: number) => 134 | line.start <= idx && idx <= line.end; 135 | 136 | for (const lineInfo of lineIndexes) { 137 | if (isIndexOnLine(lineInfo, startIdx)) { 138 | start = createPosition(lineInfo.line, startIdx - lineInfo.start); 139 | } 140 | if (isIndexOnLine(lineInfo, endIdx)) { 141 | const end = createPosition(lineInfo.line, endIdx - lineInfo.start); 142 | 143 | return { 144 | start, 145 | end, 146 | }; 147 | } 148 | } 149 | 150 | return null; 151 | } 152 | 153 | /** 154 | * Should function with given name be excluded 155 | * 156 | * @param keywordName 157 | */ 158 | function excludeKeyword(keywordName: string) { 159 | // Exclude private keywords 160 | return keywordName.startsWith("_"); 161 | } 162 | 163 | function startsWithWs(str: string) { 164 | return ( 165 | str.startsWith(" ") || 166 | str.startsWith("\t") || 167 | str.startsWith("\n") || 168 | str.startsWith("\r") 169 | ); 170 | } 171 | 172 | function findKeywords( 173 | namespace: string, 174 | data: string, 175 | lineIndexes: LineInfo[] 176 | ) { 177 | const keywords: UserKeyword[] = []; 178 | const regex = new RegExp(FUNCTION_DECLARATION_REGEX, "mg"); 179 | 180 | const matcherFn = () => regex.exec(data); 181 | findMatches(matcherFn, (result: RegExpExecArray) => { 182 | const [fullMatch, name, argsStr] = result; 183 | 184 | if (excludeKeyword(name)) { 185 | return; 186 | } 187 | 188 | if (isCommentedOut(data, result.index)) { 189 | return; 190 | } 191 | 192 | let startIdx = result.index; 193 | const endIdx = startIdx + fullMatch.length; 194 | if (startsWithWs(fullMatch)) { 195 | startIdx++; 196 | } 197 | 198 | const keywordRange = findRange(lineIndexes, startIdx, endIdx); 199 | 200 | const args = parseArguments(argsStr, keywordRange); 201 | 202 | const keyword = Object.assign( 203 | new UserKeyword(new NamespacedIdentifier(namespace, name, keywordRange)), 204 | { location: keywordRange } 205 | ); 206 | 207 | if (!_.isEmpty(args)) { 208 | keyword.arguments = args; 209 | } 210 | 211 | keywords.push(keyword); 212 | }); 213 | 214 | return keywords; 215 | } 216 | 217 | /** 218 | * Parses arguments from given string 219 | * 220 | * @param args Argument names comma separated 221 | * @param range 222 | */ 223 | function parseArguments(args: string, range: Range) { 224 | // Python class instance args are ignore 225 | const isSelfArg = (arg: string, idx: number) => arg === "self" && idx === 0; 226 | 227 | const argumentDeclarations = args 228 | .split(",") 229 | .map(arg => arg.trim()) 230 | .filter((arg, idx) => !_.isEmpty(arg) && !isSelfArg(arg, idx)) 231 | .map(argumentName => { 232 | let value: string; 233 | 234 | if (argumentName.includes("=")) { 235 | const nameAndValue = argumentName.split("="); 236 | 237 | argumentName = nameAndValue[0]; 238 | value = nameAndValue[1]; 239 | } 240 | 241 | // Assume all arguments are scalars with lack of better knowledge 242 | return new ScalarDeclaration( 243 | new Identifier(argumentName, range), 244 | new Literal(value, range), 245 | range 246 | ); 247 | }); 248 | 249 | if (argumentDeclarations.length === 0) { 250 | return undefined; 251 | } 252 | 253 | return new Arguments(new Identifier("", range), argumentDeclarations, range); 254 | } 255 | -------------------------------------------------------------------------------- /server/src/python-parser/test/python-parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as chai from "chai"; 3 | 4 | import { PythonParser } from "../python-parser"; 5 | 6 | import { 7 | TestSuite, 8 | KeywordsTable, 9 | UserKeyword, 10 | Identifier, 11 | Arguments, 12 | ScalarDeclaration, 13 | Literal, 14 | NamespacedIdentifier, 15 | } from "../../parser/models"; 16 | import { Range, createRange, createPosition } from "../../utils/position"; 17 | import { SourceLocation } from "../../parser/table-models"; 18 | 19 | const parser = new PythonParser(); 20 | const NAMESPACE = ""; 21 | 22 | function createLocation( 23 | startLine: number, 24 | startColumn: number, 25 | endLine: number, 26 | endColumn: number 27 | ): Range { 28 | return createRange( 29 | createPosition(startLine, startColumn), 30 | createPosition(endLine, endColumn) 31 | ); 32 | } 33 | 34 | function parseAndAssert(data: string, expected: any) { 35 | const actual = parser.parseFile(data, NAMESPACE); 36 | 37 | chai.assert.deepEqual(actual, expected); 38 | } 39 | 40 | function createSuite(location: SourceLocation, keywords: UserKeyword[]) { 41 | return Object.assign(new TestSuite(location), { 42 | keywordsTable: Object.assign(new KeywordsTable(location), { 43 | keywords, 44 | }), 45 | }); 46 | } 47 | 48 | function createKeyword( 49 | keywordName: string, 50 | location: SourceLocation, 51 | args?: [string, string][] 52 | ) { 53 | const keyword = new UserKeyword( 54 | new NamespacedIdentifier(NAMESPACE, keywordName, location) 55 | ); 56 | keyword.location = location; 57 | 58 | if (args) { 59 | keyword.arguments = new Arguments( 60 | new Identifier("", location), 61 | args.map( 62 | ([argName, value]) => 63 | new ScalarDeclaration( 64 | new Identifier(argName, location), 65 | new Literal(value, location), 66 | location 67 | ) 68 | ), 69 | location 70 | ); 71 | } 72 | 73 | return keyword; 74 | } 75 | 76 | describe("PythonParser", () => { 77 | it("should parse function without args", () => { 78 | const data = "def name():\n print('hello')"; 79 | 80 | const expected = createSuite(createLocation(0, 0, 1, 16), [ 81 | createKeyword("name", createLocation(0, 0, 0, 11)), 82 | ]); 83 | 84 | parseAndAssert(data, expected); 85 | }); 86 | 87 | it("should parse function with one argument", () => { 88 | const data = "def name(arg1):\n print('hello')"; 89 | 90 | const expected = createSuite(createLocation(0, 0, 1, 16), [ 91 | createKeyword("name", createLocation(0, 0, 0, 15), [["arg1", undefined]]), 92 | ]); 93 | 94 | parseAndAssert(data, expected); 95 | }); 96 | 97 | it("should parse argument default value", () => { 98 | const data = "def name(arg1=200):\n print('hello')"; 99 | 100 | const expected = createSuite(createLocation(0, 0, 1, 16), [ 101 | createKeyword("name", createLocation(0, 0, 0, 19), [["arg1", "200"]]), 102 | ]); 103 | 104 | parseAndAssert(data, expected); 105 | }); 106 | 107 | it("should skip self argument", () => { 108 | const data = "def name(self):\n print('hello')"; 109 | 110 | const expected = createSuite(createLocation(0, 0, 1, 16), [ 111 | createKeyword("name", createLocation(0, 0, 0, 15)), 112 | ]); 113 | 114 | parseAndAssert(data, expected); 115 | }); 116 | 117 | it("should skip commented out function", () => { 118 | const data = "# def name():\n print('hello')"; 119 | const expected = createSuite(createLocation(0, 0, 1, 16), []); 120 | 121 | parseAndAssert(data, expected); 122 | }); 123 | 124 | it("should skip private function", () => { 125 | const data = "def _private():\n print('hello')"; 126 | const expected = createSuite(createLocation(0, 0, 1, 16), []); 127 | 128 | parseAndAssert(data, expected); 129 | }); 130 | 131 | it("should ignore whitespace before the function", () => { 132 | const data = " def name(arg1):\n print('hello')"; 133 | 134 | const expected = createSuite(createLocation(0, 0, 1, 16), [ 135 | createKeyword("name", createLocation(0, 3, 0, 18), [["arg1", undefined]]), 136 | ]); 137 | 138 | parseAndAssert(data, expected); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /server/src/thenable.d.ts: -------------------------------------------------------------------------------- 1 | // We need this until the LSP node libraries are on TS 2.x as well. 2 | interface Thenable extends PromiseLike {} 3 | -------------------------------------------------------------------------------- /server/src/traverse/traverse.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | import { Node } from "../parser/models"; 4 | 5 | export enum VisitorOption { 6 | Skip = "skip", 7 | Break = "break", 8 | Continue = "continue", 9 | } 10 | 11 | const NodeSettings: { 12 | [nodeName: string]: { 13 | orderEnsured: boolean; 14 | children: string[]; 15 | }; 16 | } = { 17 | TestSuite: { 18 | orderEnsured: false, 19 | children: [ 20 | "settingsTable", 21 | "variablesTable", 22 | "keywordsTable", 23 | "testCasesTable", 24 | ], 25 | }, 26 | KeywordsTable: { 27 | orderEnsured: true, 28 | children: ["keywords"], 29 | }, 30 | UserKeyword: { 31 | orderEnsured: false, 32 | children: [ 33 | "id", 34 | "steps", 35 | "arguments", 36 | "return", 37 | "documentation", 38 | "timeout", 39 | "teardown", 40 | "tags", 41 | ], 42 | }, 43 | SettingsTable: { 44 | orderEnsured: false, 45 | children: [ 46 | "suiteSetup", 47 | "suiteTeardown", 48 | "testSetup", 49 | "testTeardown", 50 | "libraryImports", 51 | "resourceImports", 52 | "variableImports", 53 | "documentation", 54 | ], 55 | }, 56 | SuiteSetting: { 57 | orderEnsured: true, 58 | children: ["name", "value"], 59 | }, 60 | LibraryImport: { 61 | orderEnsured: true, 62 | children: ["target", "args"], 63 | }, 64 | ResourceImport: { 65 | orderEnsured: true, 66 | children: ["target"], 67 | }, 68 | VariableImport: { 69 | orderEnsured: true, 70 | children: ["target"], 71 | }, 72 | VariablesTable: { 73 | orderEnsured: true, 74 | children: ["variables"], 75 | }, 76 | ScalarDeclaration: { 77 | orderEnsured: true, 78 | children: ["id", "value"], 79 | }, 80 | ListDeclaration: { 81 | orderEnsured: true, 82 | children: ["id", "values"], 83 | }, 84 | DictionaryDeclaration: { 85 | orderEnsured: true, 86 | children: ["id", "values"], 87 | }, 88 | TestCasesTable: { 89 | orderEnsured: true, 90 | children: ["testCases"], 91 | }, 92 | TestCase: { 93 | orderEnsured: false, 94 | children: [ 95 | "id", 96 | "steps", 97 | "documentation", 98 | "timeout", 99 | "setup", 100 | "teardown", 101 | "tags", 102 | ], 103 | }, 104 | Step: { 105 | orderEnsured: true, 106 | children: ["body"], 107 | }, 108 | CallExpression: { 109 | orderEnsured: true, 110 | children: ["callee", "args"], 111 | }, 112 | VariableExpression: { 113 | orderEnsured: true, 114 | children: ["id"], 115 | }, 116 | Literal: { 117 | orderEnsured: true, 118 | children: [], 119 | }, 120 | TemplateLiteral: { 121 | orderEnsured: false, 122 | children: ["quasis", "expressions"], 123 | }, 124 | Documentation: { 125 | orderEnsured: true, 126 | children: ["id", "value"], 127 | }, 128 | Arguments: { 129 | orderEnsured: true, 130 | children: ["id", "values"], 131 | }, 132 | Return: { 133 | orderEnsured: true, 134 | children: ["id", "values"], 135 | }, 136 | Timeout: { 137 | orderEnsured: true, 138 | children: ["id", "value", "message"], 139 | }, 140 | Tags: { 141 | orderEnsured: true, 142 | children: ["id", "values"], 143 | }, 144 | Setup: { 145 | orderEnsured: true, 146 | children: ["id", "keyword"], 147 | }, 148 | Teardown: { 149 | orderEnsured: true, 150 | children: ["id", "keyword"], 151 | }, 152 | }; 153 | 154 | export interface Visitor { 155 | enter?: (node: Node, parent: Node | null) => VisitorOption; 156 | leave?: (node: Node, parent: Node | null) => VisitorOption; 157 | } 158 | 159 | function visit(node: Node, parent: Node | null, visitor: Visitor) { 160 | if (visitor.enter) { 161 | return visitor.enter(node, parent); 162 | } else { 163 | return null; 164 | } 165 | } 166 | 167 | function leave(node: Node, parent: Node | null, visitor: Visitor) { 168 | if (visitor.leave) { 169 | return visitor.leave(node, parent); 170 | } else { 171 | return null; 172 | } 173 | } 174 | 175 | function internalTraverse( 176 | node: Node, 177 | parent: Node | null, 178 | visitor: Visitor 179 | ): any { 180 | // Naive recursive implementation 181 | // TODO: Remove recursivity 182 | if (!node) { 183 | return; 184 | } 185 | 186 | let visitResult = visit(node, parent, visitor); 187 | if (visitResult === VisitorOption.Break) { 188 | return VisitorOption.Break; 189 | } 190 | 191 | if (visitResult !== VisitorOption.Skip) { 192 | const nodeSettings = NodeSettings[node.type]; 193 | 194 | if (nodeSettings && !_.isEmpty(nodeSettings.children)) { 195 | // TODO: Check order 196 | 197 | nodeSettings.children.forEach(propertyName => { 198 | const childNode = (node as any)[propertyName] as Node | Node[]; 199 | 200 | if (Array.isArray(childNode)) { 201 | for (const item of childNode) { 202 | visitResult = internalTraverse(item, node, visitor); 203 | 204 | if (visitResult === VisitorOption.Break) { 205 | return VisitorOption.Break; 206 | } 207 | } 208 | } else { 209 | visitResult = internalTraverse(childNode, node, visitor); 210 | 211 | if (visitResult === VisitorOption.Break) { 212 | return VisitorOption.Break; 213 | } 214 | } 215 | 216 | return VisitorOption.Continue; 217 | }); 218 | } 219 | } 220 | 221 | return leave(node, parent, visitor); 222 | } 223 | 224 | export function traverse(node: Node, visitor: Visitor) { 225 | internalTraverse(node, null, visitor); 226 | } 227 | -------------------------------------------------------------------------------- /server/src/utils/ast-util.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "../parser/models"; 2 | import { traverse, VisitorOption } from "../traverse/traverse"; 3 | 4 | /** 5 | * Traverses given abstract syntax tree and returns the nodes 6 | * for which the matchFn returns a truthy value. 7 | * 8 | * @param ast abstract syntax tree 9 | * @param matchFn 10 | */ 11 | export function filter(ast: Node, matchFn: (node: Node) => boolean) { 12 | const nodes = [] as Node[]; 13 | 14 | traverse(ast, { 15 | enter: (node: Node) => { 16 | if (matchFn(node)) { 17 | nodes.push(node); 18 | } 19 | 20 | return VisitorOption.Continue; 21 | }, 22 | }); 23 | 24 | return nodes; 25 | } 26 | -------------------------------------------------------------------------------- /server/src/utils/position.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "../parser/models"; 2 | 3 | /** 4 | * A position in a text 5 | */ 6 | export interface Position { 7 | line: number; 8 | column: number; 9 | } 10 | 11 | export interface Location { 12 | filePath: string; 13 | position: Position; 14 | } 15 | 16 | export interface Range { 17 | start: Position; 18 | end: Position; 19 | } 20 | 21 | export function createPosition(line: number, column: number) { 22 | return { 23 | line, 24 | column, 25 | }; 26 | } 27 | 28 | export function createLocation(filePath: string, position: Position) { 29 | return { 30 | filePath, 31 | position, 32 | }; 33 | } 34 | 35 | export function createRange(start: Position, end: Position) { 36 | return { 37 | start, 38 | end, 39 | }; 40 | } 41 | 42 | /** 43 | * Converts given node's location to vscode Range 44 | * 45 | * @param node 46 | */ 47 | export function nodeLocationToRange(node: Node) { 48 | return { 49 | start: { 50 | line: node.location.start.line, 51 | character: node.location.start.column, 52 | }, 53 | end: { 54 | line: node.location.end.line, 55 | character: node.location.end.column, 56 | }, 57 | }; 58 | } 59 | 60 | /** 61 | * Checks if given node spans over the given line 62 | * 63 | * @param line 64 | * @param node 65 | */ 66 | export function isOnLine(line: number, node: Node) { 67 | if (!node) { 68 | return false; 69 | } 70 | 71 | return node.location.start.line <= line && line <= node.location.end.line; 72 | } 73 | 74 | /** 75 | * Checks if given node spans over the given position 76 | * 77 | * @param position 78 | * @param range 79 | */ 80 | export function isInRange(position: Position, range: Node) { 81 | if (!range) { 82 | return false; 83 | } 84 | 85 | const location = range.location; 86 | 87 | return ( 88 | (location.start.line < position.line || 89 | (location.start.line === position.line && 90 | location.start.column <= position.column)) && 91 | (position.line < location.end.line || 92 | (position.line === location.end.line && 93 | position.column <= location.end.column)) 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /server/src/utils/settings.ts: -------------------------------------------------------------------------------- 1 | export interface KeywordDefinition { 2 | name: string; 3 | args: string[] | string; 4 | doc: string; 5 | } 6 | 7 | export interface LibraryDefinition { 8 | name: string; 9 | version: string; 10 | keywords: KeywordDefinition[]; 11 | } 12 | 13 | export interface Settings { 14 | includePaths?: string[]; 15 | excludePaths?: string[]; 16 | logLevel?: string; 17 | libraries?: (string | LibraryDefinition)[]; 18 | } 19 | 20 | export enum LogLevel { 21 | Off, 22 | Errors, 23 | Info, 24 | Debug, 25 | } 26 | 27 | const defaultLogLevel = LogLevel.Off; 28 | 29 | export class Config { 30 | public static settings: Settings = {}; 31 | 32 | public static setSettings(settings: Settings) { 33 | Config.settings = settings; 34 | } 35 | 36 | public static getIncludeExclude() { 37 | if (!Config.settings) { 38 | return { 39 | include: [], 40 | exclude: [], 41 | }; 42 | } 43 | 44 | return { 45 | include: Config.settings.includePaths || [], 46 | exclude: Config.settings.excludePaths || [], 47 | }; 48 | } 49 | 50 | public static getLogLevel() { 51 | if (!Config.settings) { 52 | return defaultLogLevel; 53 | } 54 | 55 | switch (Config.settings.logLevel) { 56 | case "off": 57 | return LogLevel.Off; 58 | case "errors": 59 | return LogLevel.Errors; 60 | case "info": 61 | return LogLevel.Info; 62 | case "debug": 63 | return LogLevel.Debug; 64 | default: 65 | return defaultLogLevel; 66 | } 67 | } 68 | 69 | public static getLibraries() { 70 | if (!Config.settings) { 71 | return []; 72 | } 73 | 74 | return Config.settings.libraries || []; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/test-data/resources/common_resources.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Documentation Common variables and keywords 3 | Library Selenium2Library 0 implicit_wait=10 # Set explicit wait to 0 and implicit 4 | Library Collections 5 | 6 | *** Variables *** 7 | 8 | *** Keywords *** 9 | Open Default Browser 10 | Open Browser ${SERVER} ${BROWSER} 11 | Select Window title=Eficode | Eficode: Excellence in software development 12 | Maximize Browser Window 13 | 14 | 15 | 16 | Navigate To Frontpage 17 | Go To ${SERVER} 18 | 19 | Navigate To Location 20 | [Arguments] ${url} 21 | Go To ${url} 22 | Location Should Be ${url} 23 | 24 | 25 | -------------------------------------------------------------------------------- /server/test-data/resources/production.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource common_resources.robot 3 | *** Variables *** 4 | ${SERVER} http://eficode.fi 5 | ${BROWSER} firefox 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/test-data/resources/smoke_resources.robot: -------------------------------------------------------------------------------- 1 | *** Variables *** 2 | 3 | ${english_link}= English 4 | ${chinese_link}= 简体中文 5 | 6 | *** Keywords *** 7 | 8 | Get Software Pages 9 | [Documentation] Returns 2 dimensional list containing url and title for webpages 10 | @{software_finnish}= Set Variable ${SERVER}/palvelut/ohjelmistokehitys/ Eficode | Ohjelmistokehitys 11 | @{software_english}= Set Variable ${SERVER}/en/services/software-development/ Eficode | Software 12 | @{software_chinese}= Set Variable ${SERVER}/zh-hans/software-development/ Eficode | Software Development 13 | [Return] ${software_finnish} ${software_english} ${software_chinese} 14 | 15 | 16 | Verify urls are valid 17 | [Documentation] Loop 2 dimensional list and check that url is valid and has correct title 18 | [Arguments] @{valid_pages} 19 | :FOR ${page} IN @{valid_pages} 20 | \ Navigate To Location ${page[0]} 21 | \ Title Should Be ${page[1]} 22 | 23 | Choose Blog 24 | [Arguments] ${blog_name} 25 | Navigate To Location ${SERVER}/blogi/ 26 | Title Should Be Eficode | Blogi 27 | Click Link ${blog_name} 28 | 29 | Verify Blog 30 | [Arguments] ${blog_name} ${url} 31 | ${blog_title}= Generate Title For Blog From Blog Name ${blog_name} 32 | Title Should Be ${blog_title} 33 | Location Should Be ${url} 34 | 35 | Generate Title For Blog From Blog Name 36 | [Arguments] ${blog_name} 37 | ${blog_title} Set Variable Eficode | ${blog_name} 38 | [Return] ${blog_title} 39 | 40 | Verify English Locale 41 | Click Link ${english_link} 42 | Location Should Be ${SERVER}/en/ 43 | 44 | Verify Chinese Locale 45 | Click Link ${chinese_link} 46 | Location Should Be ${SERVER}/zh-hans/ 47 | -------------------------------------------------------------------------------- /server/test-data/smoke.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Resource resources/${ENVIRONMENT}.robot 3 | Resource resources/smoke_resources.robot 4 | Suite Setup Open Default Browser 5 | Test Setup Navigate To Frontpage 6 | Suite Teardown Close Browser 7 | 8 | *** Variables **** 9 | ${robot_blog_name}= Automatic testing with Robot Framework pt. 3: Setting up a continuous integration system 10 | ${robot_blog_url}= ${SERVER}/blogi/setting-up-a-ci-system/ 11 | 12 | 13 | 14 | *** Test Cases *** 15 | 16 | Blog about robotframework should exist 17 | Choose Blog ${robot_blog_name} 18 | Verify Blog ${robot_blog_name} ${robot_blog_url} 19 | 20 | Changing language should change website language 21 | Verify English Locale 22 | Verify Chinese Locale 23 | 24 | Software pages should be reachable 25 | @{valid_pages}= Get Software Pages 26 | Verify urls are valid @{valid_pages} 27 | 28 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "lib" : [ "es2016" ], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noFallthroughCasesInSwitch": true, 8 | "noImplicitAny": true, 9 | "noImplicitThis": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "outDir": "out", 13 | "rootDir": "src", 14 | "sourceMap": true, 15 | "strictBindCallApply": true, 16 | "strictFunctionTypes": true, 17 | "target": "es6" 18 | }, 19 | "include": [ 20 | "src" 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "lib": ["es2020"], 6 | "outDir": "out", 7 | "rootDir": "src", 8 | "sourceMap": true 9 | }, 10 | "include": ["src"], 11 | "exclude": ["node_modules"], 12 | "references": [{ "path": "./client" }, { "path": "./server" }] 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "jsRules": { 4 | "class-name": true, 5 | "comment-format": [ 6 | true, 7 | "check-space" 8 | ], 9 | "indent": [ 10 | true, 11 | "spaces" 12 | ], 13 | "no-duplicate-variable": true, 14 | "no-eval": true, 15 | "no-trailing-whitespace": true, 16 | "no-unsafe-finally": true, 17 | "one-line": [ 18 | true, 19 | "check-open-brace", 20 | "check-whitespace" 21 | ], 22 | "quotemark": [ 23 | true, 24 | "double" 25 | ], 26 | "semicolon": [ 27 | true, 28 | "always" 29 | ], 30 | "triple-equals": [ 31 | true, 32 | "allow-null-check" 33 | ], 34 | "variable-name": [ 35 | true, 36 | "ban-keywords" 37 | ], 38 | "whitespace": [ 39 | true, 40 | "check-branch", 41 | "check-decl", 42 | "check-operator", 43 | "check-separator", 44 | "check-type" 45 | ] 46 | }, 47 | "rules": { 48 | "arrow-parens": [true, "ban-single-arg-parens"], 49 | "ban-types": [ 50 | true, 51 | [ 52 | "Object" 53 | ], 54 | [ 55 | "String" 56 | ], 57 | [ 58 | "Boolean" 59 | ], 60 | [ 61 | "Number" 62 | ] 63 | ], 64 | "class-name": true, 65 | "comment-format": [ 66 | true, 67 | "check-space" 68 | ], 69 | "indent": [ 70 | true, 71 | "spaces" 72 | ], 73 | "interface-name": [true, "never-prefix"], 74 | "max-classes-per-file": [ 75 | false 76 | ], 77 | "object-literal-sort-keys": false, 78 | "object-literal-key-quotes": [ 79 | true, 80 | "consistent-as-needed" 81 | ], 82 | "no-console": [ 83 | false 84 | ], 85 | "no-eval": true, 86 | "no-internal-module": true, 87 | "no-shadowed-variable": false, 88 | "no-trailing-whitespace": true, 89 | "no-unsafe-finally": true, 90 | "no-unused-expression": false, 91 | "no-var-keyword": true, 92 | "one-line": [ 93 | true, 94 | "check-open-brace", 95 | "check-whitespace" 96 | ], 97 | "ordered-imports": [ 98 | false 99 | ], 100 | "quotemark": [ 101 | true, 102 | "double" 103 | ], 104 | "semicolon": [ 105 | true, 106 | "always" 107 | ], 108 | "trailing-comma": [ 109 | false 110 | ], 111 | "triple-equals": [ 112 | true, 113 | "allow-null-check" 114 | ], 115 | "typedef-whitespace": [ 116 | true, 117 | { 118 | "call-signature": "nospace", 119 | "index-signature": "nospace", 120 | "parameter": "nospace", 121 | "property-declaration": "nospace", 122 | "variable-declaration": "nospace" 123 | } 124 | ], 125 | "variable-name": [ 126 | true, 127 | "ban-keywords" 128 | ], 129 | "whitespace": [ 130 | true, 131 | "check-branch", 132 | "check-decl", 133 | "check-operator", 134 | "check-separator", 135 | "check-type" 136 | ] 137 | } 138 | } 139 | --------------------------------------------------------------------------------