├── .circleci └── config.yml ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── release-please.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json ├── snippets.code-snippets ├── tasks.json └── templates │ ├── css.lict │ ├── html.lict │ └── ts.lict ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── commitlint.config.js ├── docs └── CODEOWNERS ├── jest.config.js ├── package.json ├── src ├── completion.test.ts ├── completion.ts ├── completion │ ├── SoqlCompletionErrorStrategy.ts │ ├── soql-functions.ts │ └── soql-query-analysis.ts ├── index.ts ├── query-validation-feature.ts ├── server.ts ├── validator.test.ts └── validator.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | slack: circleci/slack@3.4.2 5 | win: circleci/windows@2.4.0 6 | 7 | parameters: 8 | node-version: 9 | type: string 10 | default: '14.15.5' 11 | 12 | variables: 13 | - &node-build-image 14 | - image: circleci/node:12.16.1-stretch 15 | 16 | jobs: 17 | 18 | language-server: 19 | executor: win/default 20 | environment: 21 | NODE_VERSION: << pipeline.parameters.node-version >> 22 | steps: 23 | - checkout 24 | - restore_cache: 25 | key: yarn-cache-{{ checksum "yarn.lock" }} 26 | - run: 27 | name: 'Install node' 28 | shell: bash.exe 29 | command: | 30 | echo 'nvm ls: ' 31 | nvm ls 32 | nvm install $NODE_VERSION 33 | nvm use $NODE_VERSION 34 | - run: 35 | name: 'Install yarn' 36 | shell: bash.exe 37 | command: | 38 | npm install --global yarn 39 | yarn --version 40 | - run: yarn install 41 | - save_cache: 42 | key: yarn-cache-{{ checksum "yarn.lock" }} 43 | paths: 44 | - ./node_modules 45 | - ~/.cache/yarn 46 | - run: 47 | name: Build Packages 48 | shell: bash.exe 49 | command: | 50 | echo 'Building...' 51 | echo 'Node version: ' 52 | node --version 53 | echo 'Node version: ' 54 | yarn --version 55 | yarn run build 56 | - run: 57 | name: 'Run Unit Tests' 58 | shell: bash.exe 59 | command: | 60 | echo 'Run Unit Tests' 61 | mkdir -p ./test-results/junit 62 | JEST_JUNIT_OUTPUT_DIR=./test-results yarn run test:unit:coverage --ci --reporters=default --reporters=jest-junit 63 | - store_test_results: 64 | path: ./test-results 65 | - run: 66 | name: Upload coverage report to Codecov 67 | shell: bash.exe 68 | command: bash <(curl -s https://codecov.io/bash) -t ${CODECOV_TOKEN} 69 | 70 | notify_slack: 71 | docker: *node-build-image 72 | steps: 73 | - slack/notify: 74 | channel: web-tools-bot 75 | title: "Success: ${CIRCLE_USERNAME}'s commit-workflow" 76 | title_link: 'https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}' 77 | color: '#9bcd9b' 78 | message: "${CIRCLE_USERNAME}'s workflow in ${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}\n(${CIRCLE_BRANCH})" 79 | include_project_field: false 80 | include_visit_job_action: false 81 | include_job_number_field: false 82 | 83 | workflows: 84 | version: 2 85 | commit-workflow: 86 | jobs: 87 | - language-server 88 | - notify_slack: 89 | requires: 90 | - language-server 91 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["eslint-config-salesforce-typescript"], 5 | "rules": { 6 | "no-shadow": "off", 7 | "@typescript-eslint/no-shadow": "error" 8 | } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 12 | 13 | 16 | 17 | **OS and version**: 18 | 19 | **VS Code Version**: 20 | 21 | **SFDX CLI Version**: 22 | 23 | 24 | ### Summary 25 | 26 | _Short summary of what is going on or to provide context_. 27 | 28 | ### Steps To Reproduce: 29 | 30 | 1. This is step 1. 31 | 1. This is step 2. All steps should start with '1.' 32 | 33 | ### Expected result 34 | 35 | _Describe what should have happened_. 36 | 37 | ### Actual result 38 | 39 | _Describe what actually happened instead_. 40 | 41 | ### Additional information 42 | 43 | _Feel free to attach a screenshot_. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 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 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does this PR do? 2 | 3 | ### What issues does this PR fix or reference? 4 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: # <- this allows triggering from github's UI 3 | push: 4 | branches: 5 | - main 6 | name: release-please 7 | jobs: 8 | release-please: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: GoogleCloudPlatform/release-please-action@v2 12 | id: release 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | release-type: node 16 | package-name: soql-language-server 17 | # The logic below handles the npm publication: 18 | - uses: actions/checkout@v2 19 | # these if statements ensure that a publication only occurs when 20 | # a new release is created: 21 | if: ${{ steps.release.outputs.release_created }} 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: 12 25 | registry-url: 'https://registry.npmjs.org' 26 | if: ${{ steps.release.outputs.release_created }} 27 | - run: | 28 | yarn install --frozen-lockfile 29 | yarn build 30 | npm publish 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 33 | if: ${{ steps.release.outputs.release_created }} 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | Dependencies/package 5 | yarn-error.log 6 | .DS_Store 7 | package-lock.json 8 | test-results/ 9 | 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | Dependencies/ 3 | jest.config*js 4 | tsconfig.json 5 | package-lock.json 6 | yarn.lock 7 | .circleci 8 | .github 9 | .vscode 10 | commitlint.config.js 11 | docs/ 12 | CODE_OF_CONDUCT.md 13 | SECURITY.md 14 | 15 | # test files 16 | /lib/**/*.test.d.ts 17 | /lib/**/*.test.js 18 | /lib/**/*.test.js.map 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@salesforce/prettier-config" 2 | 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | // Git 6 | "eamodio.gitlens", 7 | "donjayamanne.githistory", 8 | // Linter 9 | "dbaeumer.vscode-eslint", 10 | "eg2.tslint", 11 | // Formatting 12 | "esbenp.prettier-vscode", 13 | "stkb.rewrap", 14 | // Testing 15 | "spoonscen.es6-mocha-snippets" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to Chrome", 9 | "port": 9222, 10 | "request": "attach", 11 | "type": "pwa-chrome", 12 | "webRoot": "${workspaceFolder}" 13 | }, 14 | { 15 | "type": "chrome", 16 | "request": "launch", 17 | "name": "Launch Chrome against localhost", 18 | "url": "http://localhost:3001", 19 | "webRoot": "${workspaceFolder}" 20 | }, 21 | { 22 | "name": "Attach-Debugger", 23 | "type": "node", 24 | "request": "attach", 25 | "port": 9229 26 | }, 27 | { 28 | "name": "Debug Tests - Language Server", 29 | "type": "node", 30 | "request": "launch", 31 | "runtimeArgs": [ 32 | "--inspect-brk", 33 | "${workspaceRoot}/node_modules/.bin/jest", 34 | "--runInBand", 35 | ], 36 | "console": "integratedTerminal", 37 | "internalConsoleOptions": "neverOpen", 38 | "port": 9229 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "license-header-manager.excludeExtensions": [ 3 | ".sh", 4 | ".js", 5 | "**.test.**", 6 | "**.spect.**" 7 | ], 8 | "license-header-manager.excludeFolders": [ 9 | "node_modules", 10 | "coverage", 11 | "dist", 12 | "docs", 13 | "scripts", 14 | "lib" 15 | ], 16 | "files.exclude": { 17 | "**/.git": true, 18 | "**/.svn": true, 19 | "**/.hg": true, 20 | "**/CVS": true, 21 | "**/.DS_Store": true, 22 | "**/.vscode-test": true 23 | }, 24 | "search.exclude": { 25 | "**/dist": true, 26 | "**/lib": true 27 | }, 28 | "editor.tabSize": 2, 29 | "rewrap.wrappingColumn": 80, 30 | "editor.formatOnSave": true, 31 | "workbench.colorCustomizations": { 32 | "activityBar.background": "#b45151", 33 | "titleBar.activeBackground": "#b45151", 34 | "titleBar.activeForeground": "#f3f1ef" 35 | }, 36 | "editor.defaultFormatter": "esbenp.prettier-vscode" 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your salesforcedx-vscode workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | // Example: 9 | // "Print to console": { 10 | // "scope": "javascript,typescript", 11 | // "prefix": "log", 12 | // "body": [ 13 | // "console.log('$1');", 14 | // "$2" 15 | // ], 16 | // "description": "Log output to console" 17 | // } 18 | "Insert Copyright Header": { 19 | "prefix": "copyright", 20 | "body": [ 21 | "/*", 22 | " * Copyright (c) 2020, salesforce.com, inc.", 23 | " * All rights reserved.", 24 | " * Licensed under the BSD 3-Clause license.", 25 | " * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause", 26 | " */", 27 | "$0" 28 | ], 29 | "description": "Insert the Salesforce copyright header" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "test:unit:debug", 7 | "path": "packages/soql-builder-ui/", 8 | "group": "test", 9 | "label": "lwc-services-debug", 10 | "detail": "lwc-services test:unit --debug", 11 | "isBackground": false 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/templates/css.lict: -------------------------------------------------------------------------------- 1 | Copyright (c) %(CurrentYear), salesforce.com, inc. 2 | All rights reserved. 3 | Licensed under the BSD 3-Clause license. 4 | For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 5 | -------------------------------------------------------------------------------- /.vscode/templates/html.lict: -------------------------------------------------------------------------------- 1 | Copyright (c) %(CurrentYear), salesforce.com, inc. 2 | All rights reserved. 3 | Licensed under the BSD 3-Clause license. 4 | For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 5 | -------------------------------------------------------------------------------- /.vscode/templates/ts.lict: -------------------------------------------------------------------------------- 1 | Copyright (c) %(CurrentYear), salesforce.com, inc. 2 | All rights reserved. 3 | Licensed under the BSD 3-Clause license. 4 | For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [0.7.1](https://www.github.com/forcedotcom/soql-language-server/compare/v0.7.0...v0.7.1) (2021-06-09) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * clear diagnostics when file is closed or deleted ([4b7f7be](https://www.github.com/forcedotcom/soql-language-server/commit/4b7f7be957fc2f76274d8d541ffd2013df15ef3b)), closes [#28](https://www.github.com/forcedotcom/soql-language-server/issues/28) 9 | 10 | ## [0.7.0](https://www.github.com/forcedotcom/soql-language-server/compare/v0.6.2...v0.7.0) (2021-04-07) 11 | 12 | 13 | ### Features 14 | 15 | * improve support for relationship queries (nested SELECTs) ([4a973ee](https://www.github.com/forcedotcom/soql-language-server/commit/4a973ee3c9274c6acf647726cad5c829839fde8c)) 16 | 17 | ### [0.6.2](https://www.github.com/forcedotcom/soql-language-server/compare/v0.6.1...v0.6.2) (2021-03-02) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | 23 | ### [0.6.1](https://www.github.com/forcedotcom/soql-language-server/compare/v0.6.0...v0.6.1) (2021-02-25) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * do not publish unnecessary files ([67f437d](https://www.github.com/forcedotcom/soql-language-server/commit/67f437dfead568fe23ea6095a0a775ce2d8fe531)) 29 | 30 | ## [0.6.0](https://www.github.com/forcedotcom/soql-language-server/compare/v0.5.0...v0.6.0) (2021-02-25) 31 | 32 | 33 | ### Features 34 | 35 | * don't bundle soql-parser, depend on soql-common instead ([7511f15](https://www.github.com/forcedotcom/soql-language-server/commit/7511f15e12f924884a7b0fa22ce36440db715f6b)) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * testing auto releasing ([#11](https://www.github.com/forcedotcom/soql-language-server/issues/11)) ([a6c35c0](https://www.github.com/forcedotcom/soql-language-server/commit/a6c35c042b8bc2a1783fa8f98ec6642049e14619)) 41 | 42 | ## [0.5.0](https://www.github.com/forcedotcom/soql-language-server/compare/v0.3.4...v0.5.0) (2021-02-09) 43 | 44 | 45 | ### Other 46 | 47 | * First release from independent repo. No functional changes 48 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Salesforce Open Source Community Code of Conduct 2 | 3 | ## About the Code of Conduct 4 | 5 | Equality is a core value at Salesforce. We believe a diverse and inclusive 6 | community fosters innovation and creativity, and are committed to building a 7 | culture where everyone feels included. 8 | 9 | Salesforce open-source projects are committed to providing a friendly, safe, and 10 | welcoming environment for all, regardless of gender identity and expression, 11 | sexual orientation, disability, physical appearance, body size, ethnicity, nationality, 12 | race, age, religion, level of experience, education, socioeconomic status, or 13 | other similar personal characteristics. 14 | 15 | The goal of this code of conduct is to specify a baseline standard of behavior so 16 | that people with different social values and communication styles can work 17 | together effectively, productively, and respectfully in our open source community. 18 | It also establishes a mechanism for reporting issues and resolving conflicts. 19 | 20 | All questions and reports of abusive, harassing, or otherwise unacceptable behavior 21 | in a Salesforce open-source project may be reported by contacting the Salesforce 22 | Open Source Conduct Committee at ossconduct@salesforce.com. 23 | 24 | ## Our Pledge 25 | 26 | In the interest of fostering an open and welcoming environment, we as 27 | contributors and maintainers pledge to making participation in our project and 28 | our community a harassment-free experience for everyone, regardless of gender 29 | identity and expression, sexual orientation, disability, physical appearance, 30 | body size, ethnicity, nationality, race, age, religion, level of experience, education, 31 | socioeconomic status, or other similar personal characteristics. 32 | 33 | ## Our Standards 34 | 35 | Examples of behavior that contributes to creating a positive environment 36 | include: 37 | 38 | * Using welcoming and inclusive language 39 | * Being respectful of differing viewpoints and experiences 40 | * Gracefully accepting constructive criticism 41 | * Focusing on what is best for the community 42 | * Showing empathy toward other community members 43 | 44 | Examples of unacceptable behavior by participants include: 45 | 46 | * The use of sexualized language or imagery and unwelcome sexual attention or 47 | advances 48 | * Personal attacks, insulting/derogatory comments, or trolling 49 | * Public or private harassment 50 | * Publishing, or threatening to publish, others' private information—such as 51 | a physical or electronic address—without explicit permission 52 | * Other conduct which could reasonably be considered inappropriate in a 53 | professional setting 54 | * Advocating for or encouraging any of the above behaviors 55 | 56 | ## Our Responsibilities 57 | 58 | Project maintainers are responsible for clarifying the standards of acceptable 59 | behavior and are expected to take appropriate and fair corrective action in 60 | response to any instances of unacceptable behavior. 61 | 62 | Project maintainers have the right and responsibility to remove, edit, or 63 | reject comments, commits, code, wiki edits, issues, and other contributions 64 | that are not aligned with this Code of Conduct, or to ban temporarily or 65 | permanently any contributor for other behaviors that they deem inappropriate, 66 | threatening, offensive, or harmful. 67 | 68 | ## Scope 69 | 70 | This Code of Conduct applies both within project spaces and in public spaces 71 | when an individual is representing the project or its community. Examples of 72 | representing a project or community include using an official project email 73 | address, posting via an official social media account, or acting as an appointed 74 | representative at an online or offline event. Representation of a project may be 75 | further defined and clarified by project maintainers. 76 | 77 | ## Enforcement 78 | 79 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 80 | reported by contacting the Salesforce Open Source Conduct Committee 81 | at ossconduct@salesforce.com. All complaints will be reviewed and investigated 82 | and will result in a response that is deemed necessary and appropriate to the 83 | circumstances. The committee is obligated to maintain confidentiality with 84 | regard to the reporter of an incident. Further details of specific enforcement 85 | policies may be posted separately. 86 | 87 | Project maintainers who do not follow or enforce the Code of Conduct in good 88 | faith may face temporary or permanent repercussions as determined by other 89 | members of the project's leadership and the Salesforce Open Source Conduct 90 | Committee. 91 | 92 | ## Attribution 93 | 94 | This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home], 95 | version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. 96 | It includes adaptions and additions from [Go Community Code of Conduct][golang-coc], 97 | [CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc]. 98 | 99 | This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us]. 100 | 101 | [contributor-covenant-home]: https://www.contributor-covenant.org (https://www.contributor-covenant.org/) 102 | [golang-coc]: https://golang.org/conduct 103 | [cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md 104 | [microsoft-coc]: https://opensource.microsoft.com/codeofconduct/ 105 | [cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/ 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 5 | 6 | 1. Create a new issue before starting your project so that we can keep track of 7 | what you are trying to add/fix. That way, we can also offer suggestions or 8 | let you know if there is already an effort in progress. 9 | 1. Fork this repository. 10 | 1. The [README](README.md) has details on how to set up your environment. 11 | 1. Create a _topic_ branch in your fork based on `main`. Note, this step is recommended but technically not required if contributing using a fork. 12 | 1. Edit the code in your fork. 13 | 1. Sign CLA (see [CLA](#cla) below) 14 | 1. Send us a pull request when you are done. We'll review your code, suggest any 15 | needed changes, and merge it in. 16 | 17 | ## Committing 18 | 19 | - We follow [Conventional Commit](https://www.conventionalcommits.org/) messages. The most important prefixes you should have in mind are: 20 | 21 | - fix: which represents bug fixes, and correlates to a SemVer patch. 22 | - feat: which represents a new feature, and correlates to a SemVer minor. 23 | - feat!:, or fix!:, refactor!:, etc., which represent a breaking change (indicated by the !) and will result in a SemVer major. 24 | 25 | - We enforce coding styles using eslint and prettier. Use `yarn lint` to check. 26 | - Before git commit and push, Husky runs hooks to ensure the commit message is in the correct format and that everything lints and compiles properly. 27 | 28 | ## Branches 29 | 30 | - `main` is the only long-lived branch in this repository. It must always be healthy. 31 | - We want to keep the commit history clean and as linear as possible. 32 | - To this end, we integrate topic branches onto `main` using merge commits only of the history of the branch is clean and meaningful and the branch doesn't contain merge commits itself. 33 | - If the branch history is not clean, we squash the changes into a single commit before merging. 34 | - NOTE: It's important to also follow [Conventional Commit](https://www.conventionalcommits.org/) messages for the squash commit!. See [Committing](#Committing) above. 35 | 36 | ## Releases 37 | 38 | The release process and [CHANGELOG](CHANGELOG.md) generation is automated using [release-please](https://github.com/googleapis/release-please), which is triggered from Github actions. 39 | 40 | After every commit that lands on `main`, [release-please](https://github.com/googleapis/release-please) updates (or creates) a release PR on github. This PR includes the version number changes to `package.json` and the updates necessary for the [CHANGELOG](CHANGELOG.md) (which are inferred from the commit messages). 41 | 42 | To perform a release, simply merge the release PR to `main`. After this happens, the automation scripts will create a git tag, publish the packages to NPM and create a release entry on github. 43 | 44 | Before merging the release PR, manual changes can be made on the release branch, including changes to the CHANGELOG. 45 | 46 | ## CLA 47 | 48 | External contributors will be required to sign a Contributor's License Agreement. You can do so by going to https://cla.salesforce.com/sign-cla. 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Salesforce Platform 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SOQL Language Server 2 | 3 | Provides SOQL language capabilities for text editors, including code-completion and errors checks. 4 | This package implements the server-side of the LSP protocol. 5 | 6 | [Salesforce's SOQL VS Code extension](https://marketplace.visualstudio.com/items?itemName=salesforce.salesforcedx-vscode-soql), which lives in repo [salesforcedx-vscode](https://github.com/forcedotcom/salesforcedx-vscode), includes an LSP client implementation for this server. 7 | 8 | ## Development 9 | 10 | If you are interested in contributing, please take a look at the [CONTRIBUTING](CONTRIBUTING.md) guide. 11 | 12 | - `yarn` from the top-level directory to pull all dependencies 13 | - `yarn build` to build 14 | - `yarn run lint` to run static checks with eslint 15 | - `yarn run test` to run automated tests 16 | 17 | This package is used by VS Code extension `salesforcedx-vscode-soql` which lives in repo [salesforcedx-vscode](https://github.com/forcedotcom/salesforcedx-vscode). 18 | 19 | During development, you can work with a local copy of the `salesforcedx-vscode` repo, and configure it to use your local build from your `soql-language-server` repo using yarn/npm links. Example: 20 | 21 | ``` 22 | # Make global links available 23 | cd soql-language-server 24 | yarn link 25 | 26 | # Link to them from the VS Code SOQL extension package 27 | cd salesforcedx-vscode 28 | npm install 29 | cd ./packages/salesforcedx-vscode-soql 30 | npm link @salesforce/soql-language-server 31 | ``` 32 | 33 | With that in place, you can make changes to `soql-language-server`, build, and then relaunch the `salesforcedx-vscode` extensions from VS Code to see the changes. 34 | 35 | ### Debug Jest Test 36 | 37 | You can debug Jest test for an individual package by running the corresponding launch configuration in VS Codes _RUN_ panel. 38 | 39 | ## Resources 40 | 41 | - Doc: [SOQL and SOSL Reference](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_sosl_intro.htm) 42 | - Doc: [SOQL and SOSL Queries](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/langCon_apex_SOQL.htm) 43 | - Trailhead: [Get Started with SOQL Queries](https://trailhead.salesforce.com/content/learn/modules/soql-for-admins/get-started-with-soql-queries) 44 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security 2 | 3 | Please report any security issue to [security@salesforce.com](mailto:security@salesforce.com) 4 | as soon as it is discovered. This library limits its runtime dependencies in 5 | order to reduce the total cost of ownership as much as can be, but all consumers 6 | should remain vigilant and have their security stakeholders review all third-party 7 | products (3PP) like this one and their dependencies. 8 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /docs/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dehru @jgellin-sf @jonnyhork @praksb @mysticflute @dobladez 2 | #ECCN:Open Source -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '\\.(ts)$': 'ts-jest', 5 | }, 6 | testMatch: ['**/*.+(spec|test).(ts|js)'], 7 | preset: 'ts-jest', 8 | testPathIgnorePatterns: ['/lib/', '/node_modules/'], 9 | displayName: 'language-server', 10 | verbose: true, 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@salesforce/soql-language-server", 3 | "version": "0.7.1", 4 | "description": "SOQL Language Server", 5 | "engines": { 6 | "node": "*" 7 | }, 8 | "main": "lib/index.js", 9 | "scripts": { 10 | "build": "tsc --project .", 11 | "clean": "rimraf lib && rimraf node_modules", 12 | "lint": "eslint src/", 13 | "test": "jest --runInBand", 14 | "test:unit:coverage": "jest --runInBand --coverage" 15 | }, 16 | "dependencies": { 17 | "@salesforce/soql-common": "0.2.1", 18 | "antlr4-c3": "^1.1.13", 19 | "antlr4ts": "^0.5.0-alpha.3", 20 | "debounce": "^1.2.0", 21 | "vscode-languageclient": "6.1.3", 22 | "vscode-languageserver": "6.1.1", 23 | "vscode-languageserver-protocol": "3.15.3", 24 | "vscode-languageserver-textdocument": "1.0.1" 25 | }, 26 | "resolutions": { 27 | "**/vscode-languageserver-protocol": "3.15.3" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.12.3", 31 | "@babel/template": "^7.10.4", 32 | "@commitlint/cli": "^11.0.0", 33 | "@commitlint/config-conventional": "^11.0.0", 34 | "@salesforce/prettier-config": "^0.0.2", 35 | "@types/debounce": "^1.2.0", 36 | "@types/jest": "22.2.3", 37 | "@types/vscode": "1.49.0", 38 | "@typescript-eslint/eslint-plugin": "^4.17.0", 39 | "@typescript-eslint/parser": "^4.17.0", 40 | "eslint": "^7.21.0", 41 | "eslint-config-prettier": "^8.1.0", 42 | "eslint-config-salesforce": "^0.1.0", 43 | "eslint-config-salesforce-typescript": "^0.2.0", 44 | "eslint-plugin-header": "^3.1.1", 45 | "eslint-plugin-import": "^2.22.1", 46 | "eslint-plugin-jsdoc": "^32.3.0", 47 | "eslint-plugin-prettier": "^3.3.1", 48 | "husky": "^4.3.8", 49 | "jest": "26.1.0", 50 | "jest-junit": "^12.0.0", 51 | "prettier": "^2.2.1", 52 | "rimraf": "^3.0.2", 53 | "ts-jest": "26.1.3", 54 | "typescript": "^4.0.3" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "git+https://github.com/forcedotcom/soql-language-server.git" 59 | }, 60 | "keywords": [ 61 | "soql", 62 | "language-server", 63 | "lsp" 64 | ], 65 | "author": "Salesforce", 66 | "license": "BSD-3-Clause", 67 | "husky": { 68 | "hooks": { 69 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 70 | "pre-push": "yarn run lint" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/completion.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; 8 | import { completionsFor, SoqlItemContext } from './completion'; 9 | import { soqlDateRangeLiterals, soqlParametricDateRangeLiterals } from './completion/soql-functions'; 10 | 11 | const SELECT_SNIPPET = { 12 | kind: CompletionItemKind.Snippet, 13 | label: 'SELECT ... FROM ...', 14 | insertText: 'SELECT $2 FROM $1', 15 | insertTextFormat: InsertTextFormat.Snippet, 16 | }; 17 | const INNER_SELECT_SNIPPET = { 18 | kind: CompletionItemKind.Snippet, 19 | label: '(SELECT ... FROM ...)', 20 | insertText: '(SELECT $2 FROM $1)', 21 | insertTextFormat: InsertTextFormat.Snippet, 22 | }; 23 | 24 | const typesForLTGTOperators = [ 25 | 'anyType', 26 | 'complexvalue', 27 | 'currency', 28 | 'date', 29 | 'datetime', 30 | 'double', 31 | 'int', 32 | 'percent', 33 | 'string', 34 | 'textarea', 35 | 'time', 36 | 'url', 37 | ]; 38 | const expectedSoqlContextByKeyword: { 39 | [key: string]: Partial; 40 | } = { 41 | '<': { onlyTypes: typesForLTGTOperators }, 42 | '<=': { onlyTypes: typesForLTGTOperators }, 43 | '>': { onlyTypes: typesForLTGTOperators }, 44 | '>=': { onlyTypes: typesForLTGTOperators }, 45 | 'INCLUDES (': { onlyTypes: ['multipicklist'] }, 46 | 'EXCLUDES (': { onlyTypes: ['multipicklist'] }, 47 | LIKE: { onlyTypes: ['string', 'textarea', 'time'] }, 48 | }; 49 | 50 | function newLiteralItem( 51 | soqlItemContext: SoqlItemContext, 52 | kind: CompletionItemKind, 53 | label: string, 54 | extraOptions: Partial = {} 55 | ): CompletionItem { 56 | return { 57 | label, 58 | kind, 59 | ...extraOptions, 60 | data: { 61 | soqlContext: soqlItemContext, 62 | }, 63 | }; 64 | } 65 | function expectedItemsForLiterals(soqlContext: SoqlItemContext, nillableOperator: boolean): CompletionItem[] { 66 | const items: CompletionItem[] = [ 67 | newLiteralItem(soqlContext, CompletionItemKind.Constant, '__LITERAL_VALUES_FOR_FIELD'), 68 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['boolean'] } }, CompletionItemKind.Value, 'TRUE'), 69 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['boolean'] } }, CompletionItemKind.Value, 'FALSE'), 70 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['int'] } }, CompletionItemKind.Snippet, 'nnn', { 71 | insertText: '${1:123}', 72 | insertTextFormat: InsertTextFormat.Snippet, 73 | }), 74 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['double'] } }, CompletionItemKind.Snippet, 'nnn.nnn', { 75 | insertText: '${1:123.456}', 76 | insertTextFormat: InsertTextFormat.Snippet, 77 | }), 78 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['currency'] } }, CompletionItemKind.Snippet, 'ISOCODEnnn.nn', { 79 | insertText: '${1|USD,EUR,JPY,CNY,CHF|}${2:999.99}', 80 | insertTextFormat: InsertTextFormat.Snippet, 81 | }), 82 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['string'] } }, CompletionItemKind.Snippet, 'abc123', { 83 | insertText: "'${1:abc123}'", 84 | insertTextFormat: InsertTextFormat.Snippet, 85 | }), 86 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['date'] } }, CompletionItemKind.Snippet, 'YYYY-MM-DD', { 87 | insertText: '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}$0', 88 | insertTextFormat: InsertTextFormat.Snippet, 89 | preselect: true, 90 | sortText: ' YYYY-MM-DD', 91 | }), 92 | newLiteralItem( 93 | { ...soqlContext, ...{ onlyTypes: ['datetime'] } }, 94 | CompletionItemKind.Snippet, 95 | 'YYYY-MM-DDThh:mm:ssZ', 96 | { 97 | insertText: 98 | '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}T${4:${CURRENT_HOUR}}:${5:${CURRENT_MINUTE}}:${6:${CURRENT_SECOND}}Z$0', 99 | insertTextFormat: InsertTextFormat.Snippet, 100 | preselect: true, 101 | sortText: ' YYYY-MM-DDThh:mm:ssZ', 102 | } 103 | ), 104 | ...soqlDateRangeLiterals.map((k) => 105 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['date', 'datetime'] } }, CompletionItemKind.Value, k) 106 | ), 107 | ...soqlParametricDateRangeLiterals.map((k) => 108 | newLiteralItem({ ...soqlContext, ...{ onlyTypes: ['date', 'datetime'] } }, CompletionItemKind.Snippet, k, { 109 | insertText: k.replace(':n', ':${1:nn}') + '$0', 110 | insertTextFormat: InsertTextFormat.Snippet, 111 | }) 112 | ), 113 | ]; 114 | 115 | if (nillableOperator) { 116 | items.push(newLiteralItem({ ...soqlContext, ...{ onlyNillable: true } }, CompletionItemKind.Keyword, 'NULL')); 117 | } 118 | 119 | return items; 120 | } 121 | 122 | function newKeywordItem(word: string, extraOptions: Partial = {}): CompletionItem { 123 | return Object.assign( 124 | { 125 | kind: CompletionItemKind.Keyword, 126 | label: word, 127 | }, 128 | extraOptions 129 | ); 130 | } 131 | 132 | function newKeywordItems(...words: string[]): CompletionItem[] { 133 | return words.map((s) => ({ 134 | kind: CompletionItemKind.Keyword, 135 | label: s, 136 | })); 137 | } 138 | 139 | function newKeywordItemsWithContext(sobjectName: string, fieldName: string, words: string[]): CompletionItem[] { 140 | return words.map((s) => ({ 141 | kind: CompletionItemKind.Keyword, 142 | label: s, 143 | data: { 144 | soqlContext: { 145 | sobjectName, 146 | fieldName, 147 | ...expectedSoqlContextByKeyword[s], 148 | }, 149 | }, 150 | })); 151 | } 152 | 153 | function newFunctionCallItem(name: string, soqlItemContext?: SoqlItemContext): CompletionItem { 154 | return Object.assign( 155 | { 156 | kind: CompletionItemKind.Function, 157 | label: name + '(...)', 158 | insertText: name + '($1)', 159 | insertTextFormat: InsertTextFormat.Snippet, 160 | }, 161 | soqlItemContext ? { data: { soqlContext: soqlItemContext } } : {} 162 | ); 163 | } 164 | const expectedSObjectCompletions: CompletionItem[] = [ 165 | { 166 | kind: CompletionItemKind.Class, 167 | label: '__SOBJECTS_PLACEHOLDER', 168 | }, 169 | ]; 170 | 171 | function relationshipsItem(sobjectName: string): CompletionItem { 172 | return { 173 | kind: CompletionItemKind.Class, 174 | label: '__RELATIONSHIPS_PLACEHOLDER', 175 | data: { 176 | soqlContext: { 177 | sobjectName, 178 | }, 179 | }, 180 | }; 181 | } 182 | 183 | describe('Code Completion on invalid cursor position', () => { 184 | it('Should return empty if cursor is on non-exitent line', () => { 185 | expect(completionsFor('SELECT id FROM Foo', 2, 5)).toHaveLength(0); 186 | }); 187 | }); 188 | 189 | describe('Code Completion on SELECT ...', () => { 190 | validateCompletionsFor('|', [newKeywordItem('SELECT'), SELECT_SNIPPET]); 191 | validateCompletionsFor('SELE|', [...newKeywordItems('SELECT'), SELECT_SNIPPET]); 192 | validateCompletionsFor('| FROM', newKeywordItems('SELECT')); 193 | validateCompletionsFor('SELECT|', []); 194 | 195 | // "COUNT()" can only be used on its own, unlike "COUNT(fieldName)". 196 | // So we expect it on completions only right after "SELECT" 197 | validateCompletionsFor('SELECT |', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 198 | validateCompletionsFor('SELECT\n|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 199 | validateCompletionsFor('SELECT\n |', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 200 | validateCompletionsFor('SELECT\n\n |\n\n', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 201 | validateCompletionsFor('SELECT id, |', sobjectsFieldsFor('Object')); 202 | validateCompletionsFor('SELECT id, boo,|', sobjectsFieldsFor('Object')); 203 | validateCompletionsFor('SELECT id|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 204 | validateCompletionsFor('SELECT id |', newKeywordItems('FROM')); 205 | validateCompletionsFor('SELECT COUNT() |', newKeywordItems('FROM')); 206 | validateCompletionsFor('SELECT COUNT(), |', []); 207 | 208 | // Inside Function expression: 209 | validateCompletionsFor('SELECT OwnerId, COUNT(|)', [ 210 | { 211 | kind: CompletionItemKind.Field, 212 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 213 | data: { 214 | soqlContext: { 215 | sobjectName: 'Object', 216 | onlyAggregatable: true, 217 | onlyTypes: [ 218 | 'date', 219 | 'datetime', 220 | 'double', 221 | 'int', 222 | 'string', 223 | 'combobox', 224 | 'currency', 225 | 'DataCategoryGroupReference', 226 | 'email', 227 | 'id', 228 | 'masterrecord', 229 | 'percent', 230 | 'phone', 231 | 'picklist', 232 | 'reference', 233 | 'textarea', 234 | 'url', 235 | ], 236 | }, 237 | }, 238 | }, 239 | ]); 240 | }); 241 | 242 | describe('Code Completion on select fields: SELECT ... FROM XYZ', () => { 243 | // "COUNT()" can only be used on its own, unlike "COUNT(fieldName)". 244 | // So we expect it on completions only right after "SELECT" 245 | validateCompletionsFor('SELECT | FROM Object', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 246 | validateCompletionsFor('SELECT | FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 247 | validateCompletionsFor('SELECT |FROM Object', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 248 | validateCompletionsFor('SELECT |FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 249 | validateCompletionsFor('SELECT | FROM Foo, Bar', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 250 | validateCompletionsFor('SELECT id, | FROM Foo', sobjectsFieldsFor('Foo')); 251 | validateCompletionsFor('SELECT id,| FROM Foo', sobjectsFieldsFor('Foo')); 252 | validateCompletionsFor('SELECT |, id FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 253 | validateCompletionsFor('SELECT |, id, FROM Foo', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 254 | validateCompletionsFor('SELECT id,| FROM', sobjectsFieldsFor('Object')); 255 | 256 | // with alias 257 | validateCompletionsFor('SELECT id,| FROM Foo F', sobjectsFieldsFor('Foo')); 258 | validateCompletionsFor('SELECT |, id FROM Foo F', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 259 | validateCompletionsFor('SELECT |, id, FROM Foo F', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Foo')]); 260 | }); 261 | 262 | describe('Code Completion on nested select fields: SELECT ... FROM XYZ', () => { 263 | // "COUNT()" can only be used on its own, unlike "COUNT(fieldName)". 264 | // So we expect it on completions only right after "SELECT" 265 | validateCompletionsFor('SELECT | (SELECT bar FROM Bar) FROM Foo', [ 266 | newKeywordItem('COUNT()'), 267 | ...sobjectsFieldsFor('Foo'), 268 | ]); 269 | validateCompletionsFor('SELECT (SELECT bar FROM Bar),| FROM Foo', sobjectsFieldsFor('Foo')); 270 | validateCompletionsFor('SELECT (SELECT bar FROM Bar), | FROM Foo', sobjectsFieldsFor('Foo')); 271 | validateCompletionsFor('SELECT id, | (SELECT bar FROM Bar) FROM Foo', sobjectsFieldsFor('Foo')); 272 | validateCompletionsFor('SELECT foo, (SELECT | FROM Bars) FROM Foo', [...relationshipFieldsFor('Foo', 'Bars')]); 273 | 274 | // TODO: improve ANTLR error strategy for this case: 275 | validateCompletionsFor('SELECT foo, (SELECT |, bar FROM Bars) FROM Foo', [...relationshipFieldsFor('Foo', 'Bars')], { 276 | skip: true, 277 | }); 278 | validateCompletionsFor('SELECT foo, (SELECT bar, | FROM Bars) FROM Foo', relationshipFieldsFor('Foo', 'Bars')); 279 | 280 | /* 281 | NOTE: Only 1 level of nesting is allowed. Thus, these are not valid queries: 282 | 283 | SELECT foo, (SELECT bar, (SELECT | FROM XYZ) FROM Bar) FROM Foo 284 | SELECT foo, (SELECT |, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo 285 | SELECT | (SELECT bar, (SELECT xyz FROM XYZ) FROM Bar) FROM Foo 286 | */ 287 | 288 | validateCompletionsFor('SELECT (SELECT |) FROM Foo', relationshipFieldsFor('Foo', undefined)); 289 | 290 | // We used to have special code just to handle this particular case. 291 | // Not worth it, that's why it's skipped now. 292 | // We keep the test here because it'd be nice to solve it in a generic way: 293 | validateCompletionsFor('SELECT (SELECT ), | FROM Foo', sobjectsFieldsFor('Foo'), { skip: true }); 294 | 295 | validateCompletionsFor('SELECT foo, ( | FROM Foo', newKeywordItems('SELECT')); 296 | validateCompletionsFor('SELECT foo, ( |FROM Foo', newKeywordItems('SELECT')); 297 | validateCompletionsFor('SELECT foo, (| FROM Foo', newKeywordItems('SELECT')); 298 | validateCompletionsFor('SELECT foo, (| FROM Foo', newKeywordItems('SELECT')); 299 | 300 | validateCompletionsFor('SELECT foo, (|) FROM Foo', newKeywordItems('SELECT').concat(SELECT_SNIPPET)); 301 | 302 | validateCompletionsFor('SELECT foo, (SELECT bar FROM Bar), (SELECT | FROM Xyzs) FROM Foo', [ 303 | ...relationshipFieldsFor('Foo', 'Xyzs'), 304 | ]); 305 | validateCompletionsFor( 306 | 'SELECT foo, (SELECT bar FROM Bar), (SELECT xyz, | FROM Xyzs) FROM Foo', 307 | relationshipFieldsFor('Foo', 'Xyzs') 308 | ); 309 | validateCompletionsFor( 310 | 'SELECT foo, | (SELECT bar FROM Bar), (SELECT xyz FROM Xyz) FROM Foo', 311 | sobjectsFieldsFor('Foo') 312 | ); 313 | validateCompletionsFor( 314 | 'SELECT foo, (SELECT bar FROM Bar), | (SELECT xyz FROM Xyz) FROM Foo', 315 | sobjectsFieldsFor('Foo') 316 | ); 317 | validateCompletionsFor('SELECT foo, (SELECT | FROM Bars), (SELECT xyz FROM Xyz) FROM Foo', [ 318 | ...relationshipFieldsFor('Foo', 'Bars'), 319 | ]); 320 | 321 | // With a semi-join (SELECT in WHERE clause): 322 | validateCompletionsFor( 323 | `SELECT Id, Name, | 324 | (SELECT Id, Parent.Profile.Name 325 | FROM SetupEntityAccessItems 326 | WHERE Parent.ProfileId != null) 327 | FROM ApexClass 328 | WHERE Id IN (SELECT SetupEntityId 329 | FROM SetupEntityAccess)`, 330 | sobjectsFieldsFor('ApexClass') 331 | ); 332 | }); 333 | 334 | describe('Code Completion on SELECT XYZ FROM...', () => { 335 | validateCompletionsFor('SELECT id FROM |', expectedSObjectCompletions); 336 | validateCompletionsFor('SELECT id\nFROM |', expectedSObjectCompletions); 337 | 338 | // cursor touching FROM should not complete with Sobject name 339 | validateCompletionsFor('SELECT id\nFROM|', []); 340 | validateCompletionsFor('SELECT id FROM |WHERE', expectedSObjectCompletions); 341 | validateCompletionsFor('SELECT id FROM | WHERE', expectedSObjectCompletions); 342 | validateCompletionsFor('SELECT id FROM | WHERE', expectedSObjectCompletions); 343 | validateCompletionsFor('SELECT id FROM | WHERE', expectedSObjectCompletions); 344 | validateCompletionsFor('SELECT id \nFROM |\nWHERE', expectedSObjectCompletions); 345 | 346 | validateCompletionsFor('SELECTHHH id FROMXXX |', []); 347 | }); 348 | 349 | describe('Code Completion on nested SELECT xyz FROM ...: parent-child relationship', () => { 350 | validateCompletionsFor('SELECT id, (SELECT id FROM |) FROM Foo', [relationshipsItem('Foo')]); 351 | validateCompletionsFor('SELECT id, (SELECT id FROM Foo) FROM |', expectedSObjectCompletions); 352 | validateCompletionsFor('SELECT id, (SELECT id FROM |), (SELECT id FROM Bar) FROM Foo', [relationshipsItem('Foo')]); 353 | validateCompletionsFor('SELECT id, (SELECT id FROM Foo), (SELECT id FROM |) FROM Bar', [relationshipsItem('Bar')]); 354 | validateCompletionsFor( 355 | 'SELECT id, (SELECT FROM |) FROM Bar', // No fields on inner SELECT 356 | [relationshipsItem('Bar')] 357 | ); 358 | validateCompletionsFor( 359 | 'SELECT id, (SELECT FROM |), (SELECT Id FROM Foo) FROM Bar', // No fields on SELECT 360 | [relationshipsItem('Bar')] 361 | ); 362 | }); 363 | 364 | describe('Code Completion on SELECT FROM (no columns on SELECT)', () => { 365 | validateCompletionsFor('SELECT FROM |', expectedSObjectCompletions, {}); 366 | validateCompletionsFor('SELECT\nFROM |', expectedSObjectCompletions); 367 | 368 | validateCompletionsFor('SELECT FROM | WHERE', expectedSObjectCompletions); 369 | validateCompletionsFor('SELECT\nFROM |\nWHERE\nORDER BY', expectedSObjectCompletions); 370 | 371 | describe('Cursor is still touching FROM: it should still complete with fieldnames, and not SObject names', () => { 372 | validateCompletionsFor('SELECT FROM|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 373 | 374 | validateCompletionsFor('SELECT\nFROM|', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 375 | validateCompletionsFor('SELECT\nFROM|\nWHERE', [newKeywordItem('COUNT()'), ...sobjectsFieldsFor('Object')]); 376 | }); 377 | 378 | validateCompletionsFor('SELECTHHH FROMXXX |', []); 379 | }); 380 | 381 | describe('Code Completion for ORDER BY', () => { 382 | validateCompletionsFor('SELECT id FROM Account ORDER BY |', [ 383 | { 384 | kind: CompletionItemKind.Field, 385 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 386 | data: { soqlContext: { sobjectName: 'Account', onlySortable: true } }, 387 | }, 388 | ]); 389 | 390 | // Nested, parent-child relationships: 391 | validateCompletionsFor('SELECT id, (SELECT Email FROM Contacts ORDER BY |) FROM Account', [ 392 | { 393 | kind: CompletionItemKind.Field, 394 | label: '__RELATIONSHIP_FIELDS_PLACEHOLDER', 395 | data: { soqlContext: { sobjectName: 'Account', relationshipName: 'Contacts', onlySortable: true } }, 396 | }, 397 | ]); 398 | }); 399 | 400 | describe('Code Completion for GROUP BY', () => { 401 | validateCompletionsFor('SELECT COUNT(Id) FROM Account GROUP BY |', [ 402 | { 403 | kind: CompletionItemKind.Field, 404 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 405 | data: { soqlContext: { sobjectName: 'Account', onlyGroupable: true } }, 406 | }, 407 | ...newKeywordItems('ROLLUP', 'CUBE'), 408 | ]); 409 | 410 | validateCompletionsFor('SELECT id FROM Account GROUP BY id |', [ 411 | ...newKeywordItems('FOR', 'OFFSET', 'HAVING', 'LIMIT', 'ORDER BY', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), 412 | ]); 413 | 414 | // When there are aggregated fields on SELECT, the GROUP BY clause 415 | // must include all non-aggregated fields... thus we want completion 416 | // for those preselected 417 | validateCompletionsFor('SELECT id FROM Account GROUP BY |', [ 418 | { 419 | kind: CompletionItemKind.Field, 420 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 421 | data: { 422 | soqlContext: { 423 | sobjectName: 'Account', 424 | onlyGroupable: true, 425 | mostLikelyItems: ['id'], 426 | }, 427 | }, 428 | }, 429 | ...newKeywordItems('ROLLUP', 'CUBE'), 430 | ]); 431 | validateCompletionsFor('SELECT id, MAX(id2), AVG(AnnualRevenue) FROM Account GROUP BY |', [ 432 | { 433 | kind: CompletionItemKind.Field, 434 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 435 | data: { 436 | soqlContext: { 437 | sobjectName: 'Account', 438 | onlyGroupable: true, 439 | mostLikelyItems: ['id'], 440 | }, 441 | }, 442 | }, 443 | ...newKeywordItems('ROLLUP', 'CUBE'), 444 | ]); 445 | 446 | validateCompletionsFor('SELECT ID, Name, MAX(id3), AVG(AnnualRevenue) FROM Account GROUP BY id, |', [ 447 | { 448 | kind: CompletionItemKind.Field, 449 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 450 | data: { 451 | soqlContext: { 452 | sobjectName: 'Account', 453 | onlyGroupable: true, 454 | mostLikelyItems: ['Name'], 455 | }, 456 | }, 457 | }, 458 | // NOTE: ROLLUP and CUBE not expected unless cursor right after GROUP BY 459 | ]); 460 | 461 | // Expect more than one. Also test with inner queries.. 462 | validateCompletionsFor( 463 | 'SELECT Id, Name, (SELECT Id, Id2, AboutMe FROM User), AVG(AnnualRevenue) FROM Account GROUP BY |', 464 | [ 465 | { 466 | kind: CompletionItemKind.Field, 467 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 468 | data: { 469 | soqlContext: { 470 | sobjectName: 'Account', 471 | onlyGroupable: true, 472 | mostLikelyItems: ['Id', 'Name'], 473 | }, 474 | }, 475 | }, 476 | ...newKeywordItems('ROLLUP', 'CUBE'), 477 | ] 478 | ); 479 | }); 480 | 481 | describe('Some keyword candidates after FROM clause', () => { 482 | validateCompletionsFor('SELECT id FROM Account |', [ 483 | newKeywordItem('WHERE', { preselect: true }), 484 | ...newKeywordItems('FOR', 'OFFSET', 'LIMIT', 'ORDER BY', 'GROUP BY', 'WITH', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), 485 | ]); 486 | 487 | validateCompletionsFor('SELECT id FROM Account FOR |', newKeywordItems('VIEW', 'REFERENCE')); 488 | 489 | validateCompletionsFor('SELECT id FROM Account WITH |', newKeywordItems('DATA CATEGORY')); 490 | 491 | // NOTE: GROUP BY not supported on nested (parent-child relationship) SELECTs 492 | validateCompletionsFor('SELECT Account.Name, (SELECT FirstName, LastName FROM Contacts |) FROM Account', [ 493 | newKeywordItem('WHERE', { preselect: true }), 494 | ...newKeywordItems('FOR', 'OFFSET', 'LIMIT', 'ORDER BY', 'WITH', 'UPDATE TRACKING', 'UPDATE VIEWSTAT'), 495 | ]); 496 | 497 | validateCompletionsFor('SELECT id FROM Account LIMIT |', []); 498 | }); 499 | 500 | describe('WHERE clause', () => { 501 | validateCompletionsFor('SELECT id FROM Account WHERE |', [ 502 | ...newKeywordItems('NOT'), 503 | { 504 | kind: CompletionItemKind.Field, 505 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 506 | data: { soqlContext: { sobjectName: 'Account' } }, 507 | }, 508 | ]); 509 | validateCompletionsFor('SELECT id FROM Account WHERE Name |', [ 510 | ...newKeywordItems('IN (', 'NOT IN (', '=', '!=', '<>'), 511 | ...newKeywordItemsWithContext('Account', 'Name', ['INCLUDES (', 'EXCLUDES (', '<', '<=', '>', '>=', 'LIKE']), 512 | ]); 513 | 514 | validateCompletionsFor('SELECT id FROM Account WHERE Type IN (|', [ 515 | ...newKeywordItems('SELECT'), 516 | SELECT_SNIPPET, 517 | ...expectedItemsForLiterals( 518 | { 519 | sobjectName: 'Account', 520 | fieldName: 'Type', 521 | }, 522 | true 523 | ), 524 | ]); 525 | 526 | validateCompletionsFor( 527 | "SELECT id FROM Account WHERE Type IN ('Customer', |)", 528 | expectedItemsForLiterals( 529 | { 530 | sobjectName: 'Account', 531 | fieldName: 'Type', 532 | }, 533 | true 534 | ) 535 | ); 536 | validateCompletionsFor("SELECT id FROM Account WHERE Type IN (|, 'Customer')", [ 537 | ...newKeywordItems('SELECT'), 538 | SELECT_SNIPPET, 539 | ...expectedItemsForLiterals( 540 | { 541 | sobjectName: 'Account', 542 | fieldName: 'Type', 543 | }, 544 | true 545 | ), 546 | ]); 547 | 548 | // NOTE: Unlike IN(), INCLUDES()/EXCLUDES() never support NULL in the list 549 | validateCompletionsFor( 550 | 'SELECT Channel FROM QuickText WHERE Channel INCLUDES (|', 551 | expectedItemsForLiterals( 552 | { 553 | sobjectName: 'QuickText', 554 | fieldName: 'Channel', 555 | }, 556 | false 557 | ) 558 | ); 559 | 560 | validateCompletionsFor( 561 | "SELECT Channel FROM QuickText WHERE Channel EXCLUDES('Email', |", 562 | expectedItemsForLiterals( 563 | { 564 | sobjectName: 'QuickText', 565 | fieldName: 'Channel', 566 | }, 567 | false 568 | ) 569 | ); 570 | validateCompletionsFor( 571 | 'SELECT id FROM Account WHERE Type = |', 572 | expectedItemsForLiterals( 573 | { 574 | sobjectName: 'Account', 575 | fieldName: 'Type', 576 | }, 577 | true 578 | ) 579 | ); 580 | validateCompletionsFor( 581 | "SELECT id FROM Account WHERE Type = 'Boo' OR Name = |", 582 | expectedItemsForLiterals( 583 | { 584 | sobjectName: 'Account', 585 | fieldName: 'Name', 586 | }, 587 | true 588 | ) 589 | ); 590 | validateCompletionsFor( 591 | "SELECT id FROM Account WHERE Type = 'Boo' OR Name LIKE |", 592 | expectedItemsForLiterals( 593 | { 594 | sobjectName: 'Account', 595 | fieldName: 'Name', 596 | }, 597 | false 598 | ) 599 | ); 600 | validateCompletionsFor( 601 | 'SELECT id FROM Account WHERE Account.Type = |', 602 | expectedItemsForLiterals( 603 | { 604 | sobjectName: 'Account', 605 | fieldName: 'Type', 606 | }, 607 | true 608 | ) 609 | ); 610 | 611 | validateCompletionsFor( 612 | 'SELECT Name FROM Account WHERE LastActivityDate < |', 613 | expectedItemsForLiterals( 614 | { 615 | sobjectName: 'Account', 616 | fieldName: 'LastActivityDate', 617 | }, 618 | false 619 | ) 620 | ); 621 | validateCompletionsFor( 622 | 'SELECT Name FROM Account WHERE LastActivityDate > |', 623 | expectedItemsForLiterals( 624 | { 625 | sobjectName: 'Account', 626 | fieldName: 'LastActivityDate', 627 | }, 628 | false 629 | ) 630 | ); 631 | }); 632 | 633 | describe('SELECT Function expressions', () => { 634 | validateCompletionsFor('SELECT DISTANCE(|) FROM Account', [ 635 | { 636 | kind: CompletionItemKind.Field, 637 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 638 | data: { soqlContext: { sobjectName: 'Account' } }, 639 | }, 640 | ]); 641 | 642 | validateCompletionsFor('SELECT AVG(|) FROM Account', [ 643 | { 644 | kind: CompletionItemKind.Field, 645 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 646 | data: { 647 | soqlContext: { 648 | sobjectName: 'Account', 649 | onlyAggregatable: true, 650 | onlyTypes: ['double', 'int', 'currency', 'percent'], 651 | }, 652 | }, 653 | }, 654 | ]); 655 | 656 | // COUNT is treated differently, always worth testing it separately 657 | validateCompletionsFor('SELECT COUNT(|) FROM Account', [ 658 | { 659 | kind: CompletionItemKind.Field, 660 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 661 | data: { 662 | soqlContext: { 663 | sobjectName: 'Account', 664 | onlyAggregatable: true, 665 | onlyTypes: [ 666 | 'date', 667 | 'datetime', 668 | 'double', 669 | 'int', 670 | 'string', 671 | 'combobox', 672 | 'currency', 673 | 'DataCategoryGroupReference', 674 | 'email', 675 | 'id', 676 | 'masterrecord', 677 | 'percent', 678 | 'phone', 679 | 'picklist', 680 | 'reference', 681 | 'textarea', 682 | 'url', 683 | ], 684 | }, 685 | }, 686 | }, 687 | ]); 688 | 689 | validateCompletionsFor('SELECT MAX(|) FROM Account', [ 690 | { 691 | kind: CompletionItemKind.Field, 692 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 693 | data: { 694 | soqlContext: { 695 | sobjectName: 'Account', 696 | onlyAggregatable: true, 697 | onlyTypes: [ 698 | 'date', 699 | 'datetime', 700 | 'double', 701 | 'int', 702 | 'string', 703 | 'time', 704 | 'combobox', 705 | 'currency', 706 | 'DataCategoryGroupReference', 707 | 'email', 708 | 'id', 709 | 'masterrecord', 710 | 'percent', 711 | 'phone', 712 | 'picklist', 713 | 'reference', 714 | 'textarea', 715 | 'url', 716 | ], 717 | }, 718 | }, 719 | }, 720 | ]); 721 | 722 | validateCompletionsFor('SELECT AVG(| FROM Account', [ 723 | { 724 | kind: CompletionItemKind.Field, 725 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 726 | data: { 727 | soqlContext: { 728 | sobjectName: 'Account', 729 | onlyAggregatable: true, 730 | onlyTypes: ['double', 'int', 'currency', 'percent'], 731 | }, 732 | }, 733 | }, 734 | ]); 735 | 736 | validateCompletionsFor('SELECT AVG(|), Id FROM Account', [ 737 | { 738 | kind: CompletionItemKind.Field, 739 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 740 | data: { 741 | soqlContext: { 742 | sobjectName: 'Account', 743 | onlyAggregatable: true, 744 | onlyTypes: ['double', 'int', 'currency', 'percent'], 745 | }, 746 | }, 747 | }, 748 | ]); 749 | validateCompletionsFor('SELECT Id, AVG(|) FROM Account', [ 750 | { 751 | kind: CompletionItemKind.Field, 752 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 753 | data: { 754 | soqlContext: { 755 | sobjectName: 'Account', 756 | onlyAggregatable: true, 757 | onlyTypes: ['double', 'int', 'currency', 'percent'], 758 | }, 759 | }, 760 | }, 761 | ]); 762 | 763 | // NOTE: cursor is right BEFORE the function expression: 764 | validateCompletionsFor('SELECT Id, | SUM(AnnualRevenue) FROM Account', [...sobjectsFieldsFor('Account')]); 765 | }); 766 | 767 | describe('Code Completion on "semi-join" (SELECT)', () => { 768 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT AccountId FROM |)', expectedSObjectCompletions); 769 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT FROM |)', expectedSObjectCompletions); 770 | 771 | // NOTE: The SELECT of a semi-join only accepts an "identifier" type column, no functions 772 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT | FROM Foo)', [ 773 | { 774 | kind: CompletionItemKind.Field, 775 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 776 | data: { soqlContext: { sobjectName: 'Foo', onlyTypes: ['id', 'reference'], dontShowRelationshipField: true } }, 777 | }, 778 | ]); 779 | 780 | // NOTE: The SELECT of a semi-join can only have one field, thus 781 | // we expect no completions here: 782 | validateCompletionsFor('SELECT Id FROM Account WHERE Id IN (SELECT Id, | FROM Foo)', []); 783 | }); 784 | 785 | describe('Special cases around newlines', () => { 786 | validateCompletionsFor('SELECT id FROM|\n\n\n', []); 787 | validateCompletionsFor('SELECT id FROM |\n\n', expectedSObjectCompletions); 788 | validateCompletionsFor('SELECT id FROM\n|', expectedSObjectCompletions); 789 | validateCompletionsFor('SELECT id FROM\n\n|', expectedSObjectCompletions); 790 | validateCompletionsFor('SELECT id FROM\n|\n', expectedSObjectCompletions); 791 | validateCompletionsFor('SELECT id FROM\n\n|\n\n', expectedSObjectCompletions); 792 | validateCompletionsFor('SELECT id FROM\n\n\n\n\n\n|\n\n', expectedSObjectCompletions); 793 | validateCompletionsFor('SELECT id FROM\n\n|\n\nWHERE', expectedSObjectCompletions); 794 | validateCompletionsFor('SELECT id FROM\n\n|WHERE', expectedSObjectCompletions); 795 | }); 796 | 797 | describe('Support leading comment lines (starting with // )', () => { 798 | validateCompletionsFor( 799 | `// This a comment line 800 | SELECT id FROM |`, 801 | expectedSObjectCompletions 802 | ); 803 | }); 804 | describe('Support leading comment lines (starting with // )', () => { 805 | validateCompletionsFor( 806 | `// This a comment line 1 807 | // This a comment line 2 808 | // This a comment line 3 809 | SELECT id FROM |`, 810 | expectedSObjectCompletions 811 | ); 812 | }); 813 | 814 | function validateCompletionsFor( 815 | text: string, 816 | expectedItems: CompletionItem[], 817 | options: { skip?: boolean; only?: boolean; cursorChar?: string } = {} 818 | ): void { 819 | const itFn = options.skip ? xit : options.only ? it.only : it; 820 | const cursorChar = options.cursorChar || '|'; 821 | itFn(text, () => { 822 | if (text.indexOf(cursorChar) !== text.lastIndexOf(cursorChar)) { 823 | throw new Error(`Test text must have 1 and only 1 cursor (char: ${cursorChar})`); 824 | } 825 | 826 | const [line, column] = getCursorPosition(text, cursorChar); 827 | const completions = completionsFor(text.replace(cursorChar, ''), line, column); 828 | 829 | // NOTE: we don't use Sets here because when there are failures, the error 830 | // message is not useful 831 | expectedItems.forEach((item) => expect(completions).toContainEqual(item)); 832 | completions.forEach((item) => expect(expectedItems).toContainEqual(item)); 833 | }); 834 | } 835 | 836 | function getCursorPosition(text: string, cursorChar: string): [number, number] { 837 | for (const [line, lineText] of text.split('\n').entries()) { 838 | const column = lineText.indexOf(cursorChar); 839 | if (column >= 0) return [line + 1, column + 1]; 840 | } 841 | throw new Error(`Cursor ${cursorChar} not found in ${text} !`); 842 | } 843 | 844 | function sobjectsFieldsFor(sobjectName: string): CompletionItem[] { 845 | return [ 846 | { 847 | kind: CompletionItemKind.Field, 848 | label: '__SOBJECT_FIELDS_PLACEHOLDER', 849 | data: { soqlContext: { sobjectName } }, 850 | }, 851 | ...newKeywordItems('TYPEOF'), 852 | newFunctionCallItem('AVG'), 853 | newFunctionCallItem('MIN'), 854 | newFunctionCallItem('MAX'), 855 | newFunctionCallItem('SUM'), 856 | newFunctionCallItem('COUNT'), 857 | newFunctionCallItem('COUNT_DISTINCT'), 858 | INNER_SELECT_SNIPPET, 859 | ]; 860 | } 861 | 862 | function relationshipFieldsFor(sobjectName: string, relationshipName?: string): CompletionItem[] { 863 | return [ 864 | { 865 | kind: CompletionItemKind.Field, 866 | label: '__RELATIONSHIP_FIELDS_PLACEHOLDER', 867 | data: { soqlContext: { sobjectName, relationshipName } }, 868 | }, 869 | ...newKeywordItems('TYPEOF'), 870 | ]; 871 | } 872 | -------------------------------------------------------------------------------- /src/completion.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { SoqlParser } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; 9 | import { SoqlLexer } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlLexer'; 10 | import { LowerCasingCharStream } from '@salesforce/soql-common/lib/soql-parser'; 11 | import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver'; 12 | 13 | import { CommonTokenStream, Parser, ParserRuleContext, Token, TokenStream } from 'antlr4ts'; 14 | 15 | import * as c3 from 'antlr4-c3'; 16 | import { parseHeaderComments } from '@salesforce/soql-common/lib/soqlComments'; 17 | import { 18 | soqlFunctionsByName, 19 | soqlFunctions, 20 | soqlOperators, 21 | soqlDateRangeLiterals, 22 | soqlParametricDateRangeLiterals, 23 | } from './completion/soql-functions'; 24 | import { SoqlCompletionErrorStrategy } from './completion/SoqlCompletionErrorStrategy'; 25 | import { ParsedSoqlField, SoqlQueryAnalyzer } from './completion/soql-query-analysis'; 26 | 27 | const SOBJECTS_ITEM_LABEL_PLACEHOLDER = '__SOBJECTS_PLACEHOLDER'; 28 | const SOBJECT_FIELDS_LABEL_PLACEHOLDER = '__SOBJECT_FIELDS_PLACEHOLDER'; 29 | const RELATIONSHIPS_PLACEHOLDER = '__RELATIONSHIPS_PLACEHOLDER'; 30 | const RELATIONSHIP_FIELDS_PLACEHOLDER = '__RELATIONSHIP_FIELDS_PLACEHOLDER'; 31 | const LITERAL_VALUES_FOR_FIELD = '__LITERAL_VALUES_FOR_FIELD'; 32 | const UPDATE_TRACKING = 'UPDATE TRACKING'; 33 | const UPDATE_VIEWSTAT = 'UPDATE VIEWSTAT'; 34 | const DEFAULT_SOBJECT = 'Object'; 35 | 36 | const itemsForBuiltinFunctions = soqlFunctions.map((soqlFn) => newFunctionItem(soqlFn.name)); 37 | 38 | export function completionsFor(text: string, line: number, column: number): CompletionItem[] { 39 | const lexer = new SoqlLexer(new LowerCasingCharStream(parseHeaderComments(text).headerPaddedSoqlText)); 40 | const tokenStream = new CommonTokenStream(lexer); 41 | const parser = new SoqlParser(tokenStream); 42 | parser.removeErrorListeners(); 43 | parser.errorHandler = new SoqlCompletionErrorStrategy(); 44 | 45 | const parsedQuery = parser.soqlQuery(); 46 | const completionTokenIndex = findCursorTokenIndex(tokenStream, { 47 | line, 48 | column, 49 | }); 50 | 51 | if (completionTokenIndex === undefined) { 52 | // eslint-disable-next-line no-console 53 | console.error("Couldn't find cursor position on toke stream! Lexer might be skipping some tokens!"); 54 | return []; 55 | } 56 | 57 | const c3Candidates = collectC3CompletionCandidates(parser, parsedQuery, completionTokenIndex); 58 | 59 | const soqlQueryAnalyzer = new SoqlQueryAnalyzer(parsedQuery); 60 | 61 | const itemsFromTokens: CompletionItem[] = generateCandidatesFromTokens( 62 | c3Candidates.tokens, 63 | soqlQueryAnalyzer, 64 | lexer, 65 | tokenStream, 66 | completionTokenIndex 67 | ); 68 | const itemsFromRules: CompletionItem[] = generateCandidatesFromRules( 69 | c3Candidates.rules, 70 | soqlQueryAnalyzer, 71 | tokenStream, 72 | completionTokenIndex 73 | ); 74 | 75 | const completionItems = itemsFromTokens.concat(itemsFromRules); 76 | 77 | // If we got no proposals from C3, handle some special cases "manually" 78 | return handleSpecialCases(soqlQueryAnalyzer, tokenStream, completionTokenIndex, completionItems); 79 | } 80 | 81 | function collectC3CompletionCandidates( 82 | parser: Parser, 83 | parsedQuery: ParserRuleContext, 84 | completionTokenIndex: number 85 | ): c3.CandidatesCollection { 86 | const core = new c3.CodeCompletionCore(parser); 87 | core.translateRulesTopDown = false; 88 | core.ignoredTokens = new Set([ 89 | SoqlLexer.BIND, 90 | SoqlLexer.LPAREN, 91 | SoqlLexer.DISTANCE, // Maybe handle it explicitly, as other built-in functions. Idem for COUNT 92 | SoqlLexer.COMMA, 93 | SoqlLexer.PLUS, 94 | SoqlLexer.MINUS, 95 | SoqlLexer.COLON, 96 | SoqlLexer.MINUS, 97 | ]); 98 | 99 | core.preferredRules = new Set([ 100 | SoqlParser.RULE_soqlFromExprs, 101 | SoqlParser.RULE_soqlFromExpr, 102 | SoqlParser.RULE_soqlField, 103 | SoqlParser.RULE_soqlUpdateStatsClause, 104 | SoqlParser.RULE_soqlIdentifier, 105 | SoqlParser.RULE_soqlLiteralValue, 106 | SoqlParser.RULE_soqlLikeLiteral, 107 | ]); 108 | 109 | return core.collectCandidates(completionTokenIndex, parsedQuery); 110 | } 111 | 112 | export function lastX(array: T[]): T | undefined { 113 | return array && array.length > 0 ? array[array.length - 1] : undefined; 114 | } 115 | 116 | const possibleIdentifierPrefix = /[\w]$/; 117 | const lineSeparator = /\n|\r|\r\n/g; 118 | export type CursorPosition = { line: number; column: number }; 119 | 120 | /** 121 | * @returns the token index for which we want to provide completion candidates, 122 | * which depends on the cursor possition. 123 | * 124 | * @example 125 | * ```soql 126 | * SELECT id| FROM x : Cursor touching the previous identifier token: 127 | * we want to continue completing that prior token position 128 | * SELECT id |FROM x : Cursor NOT touching the previous identifier token: 129 | * we want to complete what comes on this new position 130 | * SELECT id | FROM x : Cursor within whitespace block: we want to complete what 131 | * comes after the whitespace (we must return a non-WS token index) 132 | * ``` 133 | */ 134 | export function findCursorTokenIndex(tokenStream: TokenStream, cursor: CursorPosition): number | undefined { 135 | // NOTE: cursor position is 1-based, while token's charPositionInLine is 0-based 136 | const cursorCol = cursor.column - 1; 137 | for (let i = 0; i < tokenStream.size; i++) { 138 | const t = tokenStream.get(i); 139 | 140 | const tokenStartCol = t.charPositionInLine; 141 | const tokenEndCol = tokenStartCol + (t.text as string).length; 142 | const tokenStartLine = t.line; 143 | const tokenEndLine = 144 | t.type !== SoqlLexer.WS || !t.text ? tokenStartLine : tokenStartLine + (t.text.match(lineSeparator)?.length || 0); 145 | 146 | // NOTE: tokenEndCol makes sense only of tokenStartLine === tokenEndLine 147 | if (tokenEndLine > cursor.line || (tokenStartLine === cursor.line && tokenEndCol > cursorCol)) { 148 | if ( 149 | i > 0 && 150 | tokenStartLine === cursor.line && 151 | tokenStartCol === cursorCol && 152 | possibleIdentifierPrefix.test(tokenStream.get(i - 1).text as string) 153 | ) { 154 | return i - 1; 155 | } else if (tokenStream.get(i).type === SoqlLexer.WS) { 156 | return i + 1; 157 | } else return i; 158 | } 159 | } 160 | return undefined; 161 | } 162 | 163 | function tokenTypeToCandidateString(lexer: SoqlLexer, tokenType: number): string { 164 | return lexer.vocabulary.getLiteralName(tokenType)?.toUpperCase().replace(/^'|'$/g, '') as string; 165 | } 166 | 167 | const fieldDependentOperators: Set = new Set([ 168 | SoqlLexer.LT, 169 | SoqlLexer.GT, 170 | SoqlLexer.INCLUDES, 171 | SoqlLexer.EXCLUDES, 172 | SoqlLexer.LIKE, 173 | ]); 174 | 175 | function generateCandidatesFromTokens( 176 | tokens: Map, 177 | soqlQueryAnalyzer: SoqlQueryAnalyzer, 178 | lexer: SoqlLexer, 179 | tokenStream: TokenStream, 180 | tokenIndex: number 181 | ): CompletionItem[] { 182 | const items: CompletionItem[] = []; 183 | for (const [tokenType, followingTokens] of tokens) { 184 | // Don't propose what's already at the cursor position 185 | if (tokenType === tokenStream.get(tokenIndex).type) { 186 | continue; 187 | } 188 | 189 | // Even though the grammar allows spaces between the < > and = signs 190 | // (for example, this is valid: `field < = 'value'`), we don't want to 191 | // propose code completions like that 192 | if (tokenType === SoqlLexer.EQ && isCursorAfter(tokenStream, tokenIndex, [[SoqlLexer.LT, SoqlLexer.GT]])) { 193 | continue; 194 | } 195 | const baseKeyword = tokenTypeToCandidateString(lexer, tokenType); 196 | if (!baseKeyword) continue; 197 | 198 | const followingKeywords = followingTokens.map((t) => tokenTypeToCandidateString(lexer, t)).join(' '); 199 | 200 | let itemText = followingKeywords.length > 0 ? baseKeyword + ' ' + followingKeywords : baseKeyword; 201 | 202 | // No aggregate features on nested queries 203 | const queryInfos = soqlQueryAnalyzer.queryInfosAt(tokenIndex); 204 | if (queryInfos.length > 1 && (itemText === 'COUNT' || itemText === 'GROUP BY')) { 205 | continue; 206 | } 207 | let soqlItemContext: SoqlItemContext | undefined; 208 | 209 | if (fieldDependentOperators.has(tokenType)) { 210 | const soqlFieldExpr = soqlQueryAnalyzer.extractWhereField(tokenIndex); 211 | if (soqlFieldExpr) { 212 | soqlItemContext = { 213 | sobjectName: soqlFieldExpr.sobjectName, 214 | fieldName: soqlFieldExpr.fieldName, 215 | }; 216 | 217 | const soqlOperator = soqlOperators[itemText]; 218 | soqlItemContext.onlyTypes = soqlOperator.types; 219 | } 220 | } 221 | 222 | // Some "manual" improvements for some keywords: 223 | if (['IN', 'NOT IN', 'INCLUDES', 'EXCLUDES'].includes(itemText)) { 224 | itemText = itemText + ' ('; 225 | } else if (itemText === 'COUNT') { 226 | // NOTE: The g4 grammar declares `COUNT()` explicitly, but not `COUNT(xyz)`. 227 | // Here we cover the first case: 228 | itemText = 'COUNT()'; 229 | } 230 | 231 | const newItem = soqlItemContext 232 | ? withSoqlContext(newKeywordItem(itemText), soqlItemContext) 233 | : newKeywordItem(itemText); 234 | 235 | if (itemText === 'WHERE') { 236 | newItem.preselect = true; 237 | } 238 | 239 | items.push(newItem); 240 | 241 | // Clone extra related operators missing by C3 proposals 242 | if (['<', '>'].includes(itemText)) { 243 | items.push({ ...newItem, ...newKeywordItem(itemText + '=') }); 244 | } 245 | if (itemText === '=') { 246 | items.push({ ...newItem, ...newKeywordItem('!=') }); 247 | items.push({ ...newItem, ...newKeywordItem('<>') }); 248 | } 249 | } 250 | return items; 251 | } 252 | 253 | // eslint-disable-next-line complexity 254 | function generateCandidatesFromRules( 255 | c3Rules: Map, 256 | soqlQueryAnalyzer: SoqlQueryAnalyzer, 257 | tokenStream: TokenStream, 258 | tokenIndex: number 259 | ): CompletionItem[] { 260 | const completionItems: CompletionItem[] = []; 261 | 262 | const queryInfos = soqlQueryAnalyzer.queryInfosAt(tokenIndex); 263 | const innermostQueryInfo = queryInfos.length > 0 ? queryInfos[0] : undefined; 264 | const fromSObject = innermostQueryInfo?.sobjectName || DEFAULT_SOBJECT; 265 | const soqlItemContext: SoqlItemContext = { 266 | sobjectName: fromSObject, 267 | }; 268 | const isInnerQuery = queryInfos.length > 1; 269 | const relationshipName = isInnerQuery ? queryInfos[0].sobjectName : undefined; 270 | const parentQuerySObject = isInnerQuery ? queryInfos[1].sobjectName : undefined; 271 | 272 | for (const [ruleId, ruleData] of c3Rules) { 273 | const lastRuleId = ruleData.ruleList[ruleData.ruleList.length - 1]; 274 | 275 | switch (ruleId) { 276 | case SoqlParser.RULE_soqlUpdateStatsClause: 277 | // NOTE: We handle this one as a Rule instead of Tokens because 278 | // "TRACKING" and "VIEWSTAT" are not part of the grammar 279 | if (tokenIndex === ruleData.startTokenIndex) { 280 | completionItems.push(newKeywordItem(UPDATE_TRACKING)); 281 | completionItems.push(newKeywordItem(UPDATE_VIEWSTAT)); 282 | } 283 | break; 284 | case SoqlParser.RULE_soqlFromExprs: 285 | if (tokenIndex === ruleData.startTokenIndex) { 286 | completionItems.push(...itemsForFromExpression(soqlQueryAnalyzer, tokenIndex)); 287 | } 288 | break; 289 | 290 | case SoqlParser.RULE_soqlField: 291 | if (lastRuleId === SoqlParser.RULE_soqlSemiJoin) { 292 | completionItems.push( 293 | withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 294 | ...soqlItemContext, 295 | onlyTypes: ['id', 'reference'], 296 | dontShowRelationshipField: true, 297 | }) 298 | ); 299 | } else if (lastRuleId === SoqlParser.RULE_soqlSelectExpr) { 300 | const isCursorAtFunctionExpr: boolean = isCursorAfter(tokenStream, tokenIndex, [ 301 | [SoqlLexer.IDENTIFIER, SoqlLexer.COUNT], 302 | [SoqlLexer.LPAREN], 303 | ]); // inside a function expression (i.e.: "SELECT AVG(|" ) 304 | 305 | // SELECT | FROM Xyz 306 | if (tokenIndex === ruleData.startTokenIndex) { 307 | if (isInnerQuery) { 308 | completionItems.push( 309 | withSoqlContext(newFieldItem(RELATIONSHIP_FIELDS_PLACEHOLDER), { 310 | ...soqlItemContext, 311 | sobjectName: parentQuerySObject || '', 312 | relationshipName, 313 | }) 314 | ); 315 | } else { 316 | completionItems.push(withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), soqlItemContext)); 317 | completionItems.push(...itemsForBuiltinFunctions); 318 | completionItems.push(newSnippetItem('(SELECT ... FROM ...)', '(SELECT $2 FROM $1)')); 319 | } 320 | } 321 | // "SELECT AVG(|" 322 | else if (isCursorAtFunctionExpr) { 323 | // NOTE: This code would be simpler if the grammar had an explicit 324 | // rule for function invocation. 325 | // It's also more complicated because COUNT is a keyword type in the grammar, 326 | // and not an IDENTIFIER like all other functions 327 | const functionNameToken = searchTokenBeforeCursor(tokenStream, tokenIndex, [ 328 | SoqlLexer.IDENTIFIER, 329 | SoqlLexer.COUNT, 330 | ]); 331 | if (functionNameToken) { 332 | const soqlFn = soqlFunctionsByName[functionNameToken?.text || '']; 333 | if (soqlFn) { 334 | soqlItemContext.onlyAggregatable = soqlFn.isAggregate; 335 | soqlItemContext.onlyTypes = soqlFn.types; 336 | } 337 | } 338 | completionItems.push(withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), soqlItemContext)); 339 | } 340 | } 341 | // ... GROUP BY | 342 | else if (lastRuleId === SoqlParser.RULE_soqlGroupByExprs && tokenIndex === ruleData.startTokenIndex) { 343 | const selectedFields = innermostQueryInfo?.selectedFields || []; 344 | const groupedByFields = (innermostQueryInfo?.groupByFields || []).map((f) => f.toLowerCase()); 345 | const groupFieldDifference = selectedFields.filter((f) => !groupedByFields.includes(f.toLowerCase())); 346 | 347 | completionItems.push( 348 | withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 349 | sobjectName: fromSObject, 350 | onlyGroupable: true, 351 | mostLikelyItems: groupFieldDifference.length > 0 ? groupFieldDifference : undefined, 352 | }) 353 | ); 354 | } 355 | 356 | // ... ORDER BY | 357 | else if (lastRuleId === SoqlParser.RULE_soqlOrderByClauseField) { 358 | completionItems.push( 359 | isInnerQuery 360 | ? withSoqlContext(newFieldItem(RELATIONSHIP_FIELDS_PLACEHOLDER), { 361 | ...soqlItemContext, 362 | sobjectName: parentQuerySObject || '', 363 | relationshipName, 364 | onlySortable: true, 365 | }) 366 | : withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 367 | ...soqlItemContext, 368 | onlySortable: true, 369 | }) 370 | ); 371 | } 372 | 373 | break; 374 | 375 | // For some reason, c3 doesn't propose rule `soqlField` when inside soqlWhereExpr, 376 | // but it does propose soqlIdentifier, so we hinge off it for where expressions 377 | case SoqlParser.RULE_soqlIdentifier: 378 | if ( 379 | tokenIndex === ruleData.startTokenIndex && 380 | [SoqlParser.RULE_soqlWhereExpr, SoqlParser.RULE_soqlDistanceExpr].includes(lastRuleId) && 381 | !ruleData.ruleList.includes(SoqlParser.RULE_soqlHavingClause) 382 | ) { 383 | completionItems.push( 384 | withSoqlContext(newFieldItem(SOBJECT_FIELDS_LABEL_PLACEHOLDER), { 385 | sobjectName: fromSObject, 386 | }) 387 | ); 388 | } 389 | break; 390 | case SoqlParser.RULE_soqlLiteralValue: 391 | case SoqlParser.RULE_soqlLikeLiteral: 392 | if (!ruleData.ruleList.includes(SoqlParser.RULE_soqlHavingClause)) { 393 | const soqlFieldExpr = soqlQueryAnalyzer.extractWhereField(tokenIndex); 394 | if (soqlFieldExpr) { 395 | for (const literalItem of createItemsForLiterals(soqlFieldExpr)) completionItems.push(literalItem); 396 | } 397 | } 398 | break; 399 | } 400 | } 401 | return completionItems; 402 | } 403 | function handleSpecialCases( 404 | soqlQueryAnalyzer: SoqlQueryAnalyzer, 405 | tokenStream: TokenStream, 406 | tokenIndex: number, 407 | completionItems: CompletionItem[] 408 | ): CompletionItem[] { 409 | if (completionItems.length === 0) { 410 | // SELECT FROM | 411 | if (isCursorAfter(tokenStream, tokenIndex, [[SoqlLexer.SELECT], [SoqlLexer.FROM]])) { 412 | completionItems.push(...itemsForFromExpression(soqlQueryAnalyzer, tokenIndex)); 413 | } 414 | } 415 | 416 | // Provide smart snippet for `SELECT`: 417 | if (completionItems.some((item) => item.label === 'SELECT')) { 418 | if (!isCursorBefore(tokenStream, tokenIndex, [[SoqlLexer.FROM]])) { 419 | completionItems.push(newSnippetItem('SELECT ... FROM ...', 'SELECT $2 FROM $1')); 420 | } 421 | } 422 | return completionItems; 423 | } 424 | 425 | function itemsForFromExpression(soqlQueryAnalyzer: SoqlQueryAnalyzer, tokenIndex: number): CompletionItem[] { 426 | const completionItems: CompletionItem[] = []; 427 | const queryInfoStack = soqlQueryAnalyzer.queryInfosAt(tokenIndex); 428 | if (queryInfoStack.length === 1 || (queryInfoStack.length > 1 && queryInfoStack[0].isSemiJoin)) { 429 | completionItems.push(newObjectItem(SOBJECTS_ITEM_LABEL_PLACEHOLDER)); 430 | } else if (queryInfoStack.length > 1) { 431 | const parentQuery = queryInfoStack[1]; 432 | const sobjectName = parentQuery.sobjectName; 433 | if (sobjectName) { 434 | // NOTE: might need to pass multiple outter SObject (nested) names ? 435 | completionItems.push( 436 | withSoqlContext(newObjectItem(RELATIONSHIPS_PLACEHOLDER), { 437 | sobjectName, 438 | }) 439 | ); 440 | } 441 | } 442 | return completionItems; 443 | } 444 | 445 | function isCursorAfter(tokenStream: TokenStream, tokenIndex: number, matchingTokens: number[][]): boolean { 446 | const toMatch = matchingTokens.concat().reverse(); 447 | let matchingIndex = 0; 448 | 449 | for (let i = tokenIndex - 1; i >= 0; i--) { 450 | const t = tokenStream.get(i); 451 | if (t.channel === SoqlLexer.HIDDEN) continue; 452 | if (toMatch[matchingIndex].includes(t.type)) { 453 | matchingIndex++; 454 | if (matchingIndex === toMatch.length) return true; 455 | } else break; 456 | } 457 | return false; 458 | } 459 | function isCursorBefore(tokenStream: TokenStream, tokenIndex: number, matchingTokens: number[][]): boolean { 460 | const toMatch = matchingTokens.concat(); 461 | let matchingIndex = 0; 462 | 463 | for (let i = tokenIndex; i < tokenStream.size; i++) { 464 | const t = tokenStream.get(i); 465 | if (t.channel === SoqlLexer.HIDDEN) continue; 466 | if (toMatch[matchingIndex].includes(t.type)) { 467 | matchingIndex++; 468 | if (matchingIndex === toMatch.length) return true; 469 | } else break; 470 | } 471 | return false; 472 | } 473 | 474 | function searchTokenBeforeCursor( 475 | tokenStream: TokenStream, 476 | tokenIndex: number, 477 | searchForAnyTokenTypes: number[] 478 | ): Token | undefined { 479 | for (let i = tokenIndex - 1; i >= 0; i--) { 480 | const t = tokenStream.get(i); 481 | if (t.channel === SoqlLexer.HIDDEN) continue; 482 | if (searchForAnyTokenTypes.includes(t.type)) { 483 | return t; 484 | } 485 | } 486 | return undefined; 487 | } 488 | 489 | function newKeywordItem(text: string): CompletionItem { 490 | return { 491 | label: text, 492 | kind: CompletionItemKind.Keyword, 493 | }; 494 | } 495 | function newFunctionItem(text: string): CompletionItem { 496 | return { 497 | label: text + '(...)', 498 | kind: CompletionItemKind.Function, 499 | insertText: text + '($1)', 500 | insertTextFormat: InsertTextFormat.Snippet, 501 | }; 502 | } 503 | 504 | export interface SoqlItemContext { 505 | sobjectName: string; 506 | relationshipName?: string; 507 | fieldName?: string; 508 | onlyTypes?: string[]; 509 | onlyAggregatable?: boolean; 510 | onlyGroupable?: boolean; 511 | onlySortable?: boolean; 512 | onlyNillable?: boolean; 513 | mostLikelyItems?: string[]; 514 | dontShowRelationshipField?: boolean; 515 | } 516 | 517 | function withSoqlContext(item: CompletionItem, soqlItemCtx: SoqlItemContext): CompletionItem { 518 | item.data = { soqlContext: soqlItemCtx }; 519 | return item; 520 | } 521 | 522 | const newCompletionItem = ( 523 | text: string, 524 | kind: CompletionItemKind, 525 | extraOptions?: Partial 526 | ): CompletionItem => ({ 527 | label: text, 528 | kind, 529 | ...extraOptions, 530 | }); 531 | 532 | const newFieldItem = (text: string, extraOptions?: Partial): CompletionItem => 533 | newCompletionItem(text, CompletionItemKind.Field, extraOptions); 534 | 535 | const newConstantItem = (text: string): CompletionItem => newCompletionItem(text, CompletionItemKind.Constant); 536 | 537 | const newObjectItem = (text: string): CompletionItem => newCompletionItem(text, CompletionItemKind.Class); 538 | 539 | const newSnippetItem = (label: string, snippet: string, extraOptions?: Partial): CompletionItem => 540 | newCompletionItem(label, CompletionItemKind.Snippet, { 541 | insertText: snippet, 542 | insertTextFormat: InsertTextFormat.Snippet, 543 | ...extraOptions, 544 | }); 545 | 546 | function createItemsForLiterals(soqlFieldExpr: ParsedSoqlField): CompletionItem[] { 547 | const soqlContext = { 548 | sobjectName: soqlFieldExpr.sobjectName, 549 | fieldName: soqlFieldExpr.fieldName, 550 | }; 551 | 552 | const items: CompletionItem[] = [ 553 | withSoqlContext(newCompletionItem('TRUE', CompletionItemKind.Value), { 554 | ...soqlContext, 555 | ...{ onlyTypes: ['boolean'] }, 556 | }), 557 | withSoqlContext(newCompletionItem('FALSE', CompletionItemKind.Value), { 558 | ...soqlContext, 559 | ...{ onlyTypes: ['boolean'] }, 560 | }), 561 | withSoqlContext(newSnippetItem('nnn', '${1:123}'), { 562 | ...soqlContext, 563 | ...{ onlyTypes: ['int'] }, 564 | }), 565 | withSoqlContext(newSnippetItem('nnn.nnn', '${1:123.456}'), { 566 | ...soqlContext, 567 | ...{ onlyTypes: ['double'] }, 568 | }), 569 | withSoqlContext(newSnippetItem('ISOCODEnnn.nn', '${1|USD,EUR,JPY,CNY,CHF|}${2:999.99}'), { 570 | ...soqlContext, 571 | ...{ onlyTypes: ['currency'] }, 572 | }), 573 | withSoqlContext(newSnippetItem('abc123', "'${1:abc123}'"), { 574 | ...soqlContext, 575 | ...{ onlyTypes: ['string'] }, 576 | }), 577 | withSoqlContext( 578 | newSnippetItem( 579 | 'YYYY-MM-DD', 580 | '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}$0', 581 | // extra space prefix on sortText to make it appear first: 582 | { preselect: true, sortText: ' YYYY-MM-DD' } 583 | ), 584 | { ...soqlContext, ...{ onlyTypes: ['date'] } } 585 | ), 586 | withSoqlContext( 587 | newSnippetItem( 588 | 'YYYY-MM-DDThh:mm:ssZ', 589 | '${1:${CURRENT_YEAR}}-${2:${CURRENT_MONTH}}-${3:${CURRENT_DATE}}T${4:${CURRENT_HOUR}}:${5:${CURRENT_MINUTE}}:${6:${CURRENT_SECOND}}Z$0', 590 | // extra space prefix on sortText to make it appear first: 591 | { preselect: true, sortText: ' YYYY-MM-DDThh:mm:ssZ' } 592 | ), 593 | { ...soqlContext, ...{ onlyTypes: ['datetime'] } } 594 | ), 595 | ...soqlDateRangeLiterals.map((k) => 596 | withSoqlContext(newCompletionItem(k, CompletionItemKind.Value), { 597 | ...soqlContext, 598 | ...{ onlyTypes: ['date', 'datetime'] }, 599 | }) 600 | ), 601 | ...soqlParametricDateRangeLiterals.map((k) => 602 | withSoqlContext(newSnippetItem(k, k.replace(':n', ':${1:nn}') + '$0'), { 603 | ...soqlContext, 604 | ...{ onlyTypes: ['date', 'datetime'] }, 605 | }) 606 | ), 607 | 608 | // Give the LSP client a chance to add additional literals: 609 | withSoqlContext(newConstantItem(LITERAL_VALUES_FOR_FIELD), soqlContext), 610 | ]; 611 | 612 | const notNillableOperator = Boolean( 613 | soqlFieldExpr.operator !== undefined && soqlOperators[soqlFieldExpr.operator]?.notNullable 614 | ); 615 | if (!notNillableOperator) { 616 | items.push( 617 | withSoqlContext(newKeywordItem('NULL'), { 618 | ...soqlContext, 619 | ...{ onlyNillable: true }, 620 | }) 621 | ); 622 | } 623 | return items; 624 | } 625 | -------------------------------------------------------------------------------- /src/completion/SoqlCompletionErrorStrategy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { DefaultErrorStrategy } from 'antlr4ts/DefaultErrorStrategy'; 9 | // import { DefaultErrorStrategy } from './DefaultErrorStrategy'; 10 | import { Parser } from 'antlr4ts/Parser'; 11 | import { Token } from 'antlr4ts/Token'; 12 | 13 | import { IntervalSet } from 'antlr4ts/misc/IntervalSet'; 14 | import { SoqlParser } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; 15 | import { SoqlLexer } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlLexer'; 16 | 17 | export class SoqlCompletionErrorStrategy extends DefaultErrorStrategy { 18 | /** 19 | * The default error handling strategy is "too smart" for our code-completion purposes. 20 | * We generally do NOT want the parser to remove tokens for recovery. 21 | * 22 | * @example 23 | * ```soql 24 | * SELECT id, | FROM Foo 25 | * ``` 26 | * Here the default error strategy is drops `FROM` and makes `Foo` a field 27 | * of SELECTs' list. So we don't recognize `Foo` as the SObject we want to 28 | * query for. 29 | * 30 | * We might implement more SOQL-completion-specific logic in the future. 31 | */ 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | protected singleTokenDeletion(recognizer: Parser): Token | undefined { 34 | return undefined; 35 | } 36 | 37 | /** 38 | * More aggressive recovering from the parsing of a broken "soqlField": 39 | * keep consuming tokens until we find a COMMA or FROM (iff they are 40 | * part of the tokens recovery set) 41 | * 42 | * This helps with the extraction of the FROM expressions when the SELECT 43 | * expressions do not parse correctly. 44 | * 45 | * @example 46 | * ```soql 47 | * SELECT AVG(|) FROM Account 48 | * ``` 49 | * Here 'AVG()' fails to parse, but the default error strategy doesn't discard 'AVG' 50 | * because it matches the IDENTIFIER token of a following rule (soqlAlias rule). This 51 | * completes the soqlSelectClause and leaves '()' for the soqlFromClause rule, and 52 | * which fails to extract the values off the FROM expressions. 53 | * 54 | */ 55 | protected getErrorRecoverySet(recognizer: Parser): IntervalSet { 56 | const defaultRecoverySet = super.getErrorRecoverySet(recognizer); 57 | if (recognizer.ruleContext.ruleIndex === SoqlParser.RULE_soqlField) { 58 | const soqlFieldFollowSet = new IntervalSet(); 59 | soqlFieldFollowSet.add(SoqlLexer.COMMA); 60 | soqlFieldFollowSet.add(SoqlLexer.FROM); 61 | const intersection = defaultRecoverySet.and(soqlFieldFollowSet); 62 | if (intersection.size > 0) return intersection; 63 | } 64 | return defaultRecoverySet; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/completion/soql-functions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | /** 9 | * Metadata about SOQL built-in functions and operators 10 | * 11 | * Aggregate functions reference: 12 | * https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_agg_functions_field_types.htm 13 | * 14 | * NOTE: The g4 grammar declares `COUNT()` explicitly, but not `COUNT(xyz)`. 15 | * 16 | */ 17 | 18 | interface SOQLFunction { 19 | name: string; 20 | types: string[]; 21 | isAggregate: boolean; 22 | } 23 | 24 | export const soqlFunctions: SOQLFunction[] = [ 25 | { 26 | name: 'AVG', 27 | types: ['double', 'int', 'currency', 'percent'], 28 | isAggregate: true, 29 | }, 30 | { 31 | name: 'COUNT', 32 | types: [ 33 | 'date', 34 | 'datetime', 35 | 'double', 36 | 'int', 37 | 'string', 38 | 'combobox', 39 | 'currency', 40 | 'DataCategoryGroupReference', // ?! 41 | 'email', 42 | 'id', 43 | 'masterrecord', 44 | 'percent', 45 | 'phone', 46 | 'picklist', 47 | 'reference', 48 | 'textarea', 49 | 'url', 50 | ], 51 | isAggregate: true, 52 | }, 53 | { 54 | name: 'COUNT_DISTINCT', 55 | types: [ 56 | 'date', 57 | 'datetime', 58 | 'double', 59 | 'int', 60 | 'string', 61 | 'combobox', 62 | 'currency', 63 | 'DataCategoryGroupReference', // ?! 64 | 'email', 65 | 'id', 66 | 'masterrecord', 67 | 'percent', 68 | 'phone', 69 | 'picklist', 70 | 'reference', 71 | 'textarea', 72 | 'url', 73 | ], 74 | isAggregate: true, 75 | }, 76 | { 77 | name: 'MAX', 78 | types: [ 79 | 'date', 80 | 'datetime', 81 | 'double', 82 | 'int', 83 | 'string', 84 | 'time', 85 | 'combobox', 86 | 'currency', 87 | 'DataCategoryGroupReference', // ?! 88 | 'email', 89 | 'id', 90 | 'masterrecord', 91 | 'percent', 92 | 'phone', 93 | 'picklist', 94 | 'reference', 95 | 'textarea', 96 | 'url', 97 | ], 98 | isAggregate: true, 99 | }, 100 | { 101 | name: 'MIN', 102 | types: [ 103 | 'date', 104 | 'datetime', 105 | 'double', 106 | 'int', 107 | 'string', 108 | 'time', 109 | 'combobox', 110 | 'currency', 111 | 'DataCategoryGroupReference', // ?! 112 | 'email', 113 | 'id', 114 | 'masterrecord', 115 | 'percent', 116 | 'phone', 117 | 'picklist', 118 | 'reference', 119 | 'textarea', 120 | 'url', 121 | ], 122 | isAggregate: true, 123 | }, 124 | { 125 | name: 'SUM', 126 | types: ['int', 'double', 'currency', 'percent'], 127 | isAggregate: true, 128 | }, 129 | ]; 130 | 131 | export const soqlFunctionsByName = soqlFunctions.reduce((result, soqlFn) => { 132 | result[soqlFn.name] = soqlFn; 133 | return result; 134 | }, {} as Record); 135 | 136 | const typesForLTGTOperators = [ 137 | 'anyType', 138 | 'complexvalue', 139 | 'currency', 140 | 'date', 141 | 'datetime', 142 | 'double', 143 | 'int', 144 | 'percent', 145 | 'string', 146 | 'textarea', 147 | 'time', 148 | 'url', 149 | ]; 150 | 151 | // SOQL operators semantics. 152 | // Operators not listed here (i.e. equality operators) are allowed on all types 153 | // and allow nulls 154 | export const soqlOperators: { 155 | [key: string]: { types: string[]; notNullable: boolean }; 156 | } = { 157 | '<': { types: typesForLTGTOperators, notNullable: true }, 158 | '<=': { types: typesForLTGTOperators, notNullable: true }, 159 | '>': { types: typesForLTGTOperators, notNullable: true }, 160 | '>=': { types: typesForLTGTOperators, notNullable: true }, 161 | INCLUDES: { types: ['multipicklist'], notNullable: true }, 162 | EXCLUDES: { types: ['multipicklist'], notNullable: true }, 163 | LIKE: { types: ['string', 'textarea', 'time'], notNullable: true }, 164 | }; 165 | 166 | export const soqlDateRangeLiterals = [ 167 | 'YESTERDAY', 168 | 'TODAY', 169 | 'TOMORROW', 170 | 'LAST_WEEK', 171 | 'THIS_WEEK', 172 | 'NEXT_WEEK', 173 | 'LAST_MONTH', 174 | 'THIS_MONTH', 175 | 'NEXT_MONTH', 176 | 'LAST_90_DAYS', 177 | 'NEXT_90_DAYS', 178 | 'THIS_QUARTER', 179 | 'LAST_QUARTER', 180 | 'NEXT_QUARTER', 181 | 'THIS_YEAR', 182 | 'LAST_YEAR', 183 | 'NEXT_YEAR', 184 | 'THIS_FISCAL_QUARTER', 185 | 'LAST_FISCAL_QUARTER', 186 | 'NEXT_FISCAL_QUARTER', 187 | 'THIS_FISCAL_YEAR', 188 | 'LAST_FISCAL_YEAR', 189 | 'NEXT_FISCAL_YEAR', 190 | ]; 191 | 192 | export const soqlParametricDateRangeLiterals = [ 193 | 'LAST_N_DAYS:n', 194 | 'NEXT_N_DAYS:n', 195 | 'NEXT_N_WEEKS:n', 196 | 'LAST_N_WEEKS:n', 197 | 'NEXT_N_MONTHS:n', 198 | 'LAST_N_MONTHS:n', 199 | 'NEXT_N_QUARTERS:n', 200 | 'LAST_N_QUARTERS:n', 201 | 'NEXT_N_YEARS:n', 202 | 'LAST_N_YEARS:n', 203 | 'NEXT_N_FISCAL_QUARTERS:n', 204 | 'LAST_N_FISCAL_QUARTERS:n', 205 | 'NEXT_N_FISCAL_YEARS:n', 206 | 'LAST_N_FISCAL_YEARS:n', 207 | ]; 208 | -------------------------------------------------------------------------------- /src/completion/soql-query-analysis.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { 8 | SoqlFromExprsContext, 9 | SoqlGroupByExprsContext, 10 | SoqlInnerQueryContext, 11 | SoqlParser, 12 | SoqlQueryContext, 13 | SoqlSelectColumnExprContext, 14 | SoqlSemiJoinContext, 15 | } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParser'; 16 | import { ParserRuleContext, Token } from 'antlr4ts'; 17 | import { ParseTreeWalker, RuleNode } from 'antlr4ts/tree'; 18 | import { SoqlParserListener } from '@salesforce/soql-common/lib/soql-parser/generated/SoqlParserListener'; 19 | 20 | interface InnerSoqlQueryInfo { 21 | soqlInnerQueryNode: ParserRuleContext; 22 | select: Token; 23 | from?: Token; 24 | sobjectName?: string; 25 | selectedFields?: string[]; 26 | groupByFields?: string[]; 27 | isSemiJoin?: boolean; 28 | } 29 | 30 | export interface ParsedSoqlField { 31 | sobjectName: string; 32 | fieldName: string; 33 | operator?: string; 34 | } 35 | export class SoqlQueryAnalyzer { 36 | private innerQueriesListener = new SoqlInnerQueriesListener(); 37 | public constructor(protected parsedQueryTree: SoqlQueryContext) { 38 | ParseTreeWalker.DEFAULT.walk(this.innerQueriesListener, parsedQueryTree); 39 | } 40 | 41 | public innermostQueryInfoAt(cursorTokenIndex: number): InnerSoqlQueryInfo | undefined { 42 | const queries = this.queryInfosAt(cursorTokenIndex); 43 | return queries.length > 0 ? queries[0] : undefined; 44 | } 45 | 46 | public queryInfosAt(cursorTokenIndex: number): InnerSoqlQueryInfo[] { 47 | return this.innerQueriesListener.findQueriesAt(cursorTokenIndex); 48 | } 49 | 50 | public extractWhereField(cursorTokenIndex: number): ParsedSoqlField | undefined { 51 | const sobject = this.innermostQueryInfoAt(cursorTokenIndex)?.sobjectName; 52 | 53 | if (sobject) { 54 | const whereFieldListener = new SoqlWhereFieldListener(cursorTokenIndex, sobject); 55 | ParseTreeWalker.DEFAULT.walk(whereFieldListener, this.parsedQueryTree); 56 | return whereFieldListener.result; 57 | } else { 58 | return undefined; 59 | } 60 | } 61 | } 62 | 63 | /* eslint-disable @typescript-eslint/member-ordering */ 64 | class SoqlInnerQueriesListener implements SoqlParserListener { 65 | private innerSoqlQueries = new Map(); 66 | 67 | /** 68 | * Return the list of nested queries which cover the given token position 69 | * 70 | * @param atIndex token index 71 | * @returns the array of queryinfos ordered from the innermost to the outermost 72 | */ 73 | public findQueriesAt(atIndex: number): InnerSoqlQueryInfo[] { 74 | const innerQueries = Array.from(this.innerSoqlQueries.values()).filter((query) => 75 | this.queryContainsTokenIndex(query, atIndex) 76 | ); 77 | const sortedQueries = innerQueries.sort((queryA, queryB) => queryB.select.tokenIndex - queryA.select.tokenIndex); 78 | return sortedQueries; 79 | } 80 | 81 | private queryContainsTokenIndex(innerQuery: InnerSoqlQueryInfo, atTokenIndex: number): boolean { 82 | // NOTE: We use the parent node to take into account the enclosing 83 | // parentheses (in the case of inner SELECTs), and the whole text until EOF 84 | // (for the top-level SELECT). BTW: soqlInnerQueryNode always has a parent. 85 | const queryNode = innerQuery.soqlInnerQueryNode.parent 86 | ? innerQuery.soqlInnerQueryNode.parent 87 | : innerQuery.soqlInnerQueryNode; 88 | 89 | const startIndex = queryNode.start.tokenIndex; 90 | const stopIndex = queryNode.stop?.tokenIndex; 91 | 92 | return atTokenIndex > startIndex && !!stopIndex && atTokenIndex <= stopIndex; 93 | } 94 | 95 | private findAncestorSoqlInnerQueryContext(node: RuleNode | undefined): ParserRuleContext | undefined { 96 | let soqlInnerQueryNode = node; 97 | while ( 98 | soqlInnerQueryNode && 99 | ![SoqlParser.RULE_soqlInnerQuery, SoqlParser.RULE_soqlSemiJoin].includes(soqlInnerQueryNode.ruleContext.ruleIndex) 100 | ) { 101 | soqlInnerQueryNode = soqlInnerQueryNode.parent; 102 | } 103 | 104 | return soqlInnerQueryNode ? (soqlInnerQueryNode as ParserRuleContext) : undefined; 105 | } 106 | 107 | private innerQueryForContext(ctx: RuleNode): InnerSoqlQueryInfo | undefined { 108 | const soqlInnerQueryNode = this.findAncestorSoqlInnerQueryContext(ctx); 109 | if (soqlInnerQueryNode) { 110 | const selectFromPair = this.innerSoqlQueries.get(soqlInnerQueryNode.start.tokenIndex); 111 | return selectFromPair; 112 | } 113 | return undefined; 114 | } 115 | 116 | public enterSoqlInnerQuery(ctx: SoqlInnerQueryContext): void { 117 | this.innerSoqlQueries.set(ctx.start.tokenIndex, { 118 | select: ctx.start, 119 | soqlInnerQueryNode: ctx, 120 | }); 121 | } 122 | 123 | public enterSoqlSemiJoin(ctx: SoqlSemiJoinContext): void { 124 | this.innerSoqlQueries.set(ctx.start.tokenIndex, { 125 | select: ctx.start, 126 | isSemiJoin: true, 127 | soqlInnerQueryNode: ctx, 128 | }); 129 | } 130 | 131 | public exitSoqlFromExprs(ctx: SoqlFromExprsContext): void { 132 | const selectFromPair = this.innerQueryForContext(ctx); 133 | 134 | if (ctx.children && ctx.children.length > 0 && selectFromPair) { 135 | const fromToken = ctx.parent?.start as Token; 136 | const sobjectName = ctx.getChild(0).getChild(0).text; 137 | selectFromPair.from = fromToken; 138 | selectFromPair.sobjectName = sobjectName; 139 | } 140 | } 141 | 142 | public enterSoqlSelectColumnExpr(ctx: SoqlSelectColumnExprContext): void { 143 | if (ctx.soqlField().childCount === 1) { 144 | const soqlField = ctx.soqlField(); 145 | const soqlIdentifiers = soqlField.soqlIdentifier(); 146 | if (soqlIdentifiers.length === 1) { 147 | const selectFromPair = this.innerQueryForContext(ctx); 148 | if (selectFromPair) { 149 | if (!selectFromPair.selectedFields) { 150 | selectFromPair.selectedFields = []; 151 | } 152 | selectFromPair.selectedFields.push(soqlIdentifiers[0].text); 153 | } 154 | } 155 | } 156 | } 157 | 158 | public enterSoqlGroupByExprs(ctx: SoqlGroupByExprsContext): void { 159 | const groupByFields: string[] = []; 160 | 161 | ctx.soqlField().forEach((soqlField) => { 162 | const soqlIdentifiers = soqlField.soqlIdentifier(); 163 | if (soqlIdentifiers.length === 1) { 164 | groupByFields.push(soqlIdentifiers[0].text); 165 | } 166 | }); 167 | 168 | if (groupByFields.length > 0) { 169 | const selectFromPair = this.innerQueryForContext(ctx); 170 | 171 | if (selectFromPair) { 172 | selectFromPair.groupByFields = groupByFields; 173 | } 174 | } 175 | } 176 | } 177 | 178 | class SoqlWhereFieldListener implements SoqlParserListener { 179 | private resultDistance = Number.MAX_VALUE; 180 | public result?: ParsedSoqlField; 181 | 182 | public constructor(private readonly cursorTokenIndex: number, private sobject: string) {} 183 | 184 | public enterEveryRule(ctx: ParserRuleContext): void { 185 | if (ctx.ruleContext.ruleIndex === SoqlParser.RULE_soqlWhereExpr) { 186 | if (ctx.start.tokenIndex <= this.cursorTokenIndex) { 187 | const distance = this.cursorTokenIndex - ctx.start.tokenIndex; 188 | if (distance < this.resultDistance) { 189 | this.resultDistance = distance; 190 | const soqlField = ctx.getChild(0).text; 191 | 192 | // Handle basic "dot" expressions 193 | // TODO: Support Aliases 194 | const fieldComponents = soqlField.split('.', 2); 195 | if (fieldComponents[0] === this.sobject) { 196 | fieldComponents.shift(); 197 | } 198 | 199 | const operator = ctx.childCount > 2 ? ctx.getChild(1).text : undefined; 200 | 201 | this.result = { 202 | sobjectName: this.sobject, 203 | fieldName: fieldComponents.join('.'), 204 | operator, 205 | }; 206 | } 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | export { default as QueryValidationFeature } from './query-validation-feature'; 8 | export { SoqlItemContext } from './completion'; 9 | 10 | export const enum RequestTypes { 11 | RunQuery = 'runQuery', 12 | } 13 | 14 | export interface RunQueryResponse { 15 | result?: string; 16 | error?: RunQueryError; 17 | } 18 | 19 | export interface RunQueryError { 20 | name: string; 21 | errorCode: string; 22 | message: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/query-validation-feature.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | import { StaticFeature, ClientCapabilities } from 'vscode-languageclient'; 8 | 9 | export default class QueryValidationFeature implements StaticFeature { 10 | public static hasRunQueryValidation(capabilities: ClientCapabilities): boolean { 11 | const customCapabilities: ClientCapabilities & { 12 | soql?: { runQuery: boolean }; 13 | } = capabilities; 14 | return customCapabilities?.soql?.runQuery || false; 15 | } 16 | 17 | public initialize(): void { 18 | /* do nothing */ 19 | } 20 | 21 | public fillClientCapabilities(capabilities: ClientCapabilities): void { 22 | const customCapabilities: ClientCapabilities & { 23 | soql?: { runQuery: boolean }; 24 | } = capabilities; 25 | customCapabilities.soql = { 26 | ...(customCapabilities.soql || {}), 27 | runQuery: true, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { 9 | createConnection, 10 | TextDocuments, 11 | ProposedFeatures, 12 | InitializeParams, 13 | TextDocumentSyncKind, 14 | InitializeResult, 15 | TextDocumentPositionParams, 16 | CompletionItem, 17 | DidChangeWatchedFilesParams, 18 | FileChangeType, 19 | DocumentUri, 20 | } from 'vscode-languageserver'; 21 | import { TextDocument } from 'vscode-languageserver-textdocument'; 22 | import { Validator } from './validator'; 23 | import QueryValidationFeature from './query-validation-feature'; 24 | import { completionsFor } from './completion'; 25 | 26 | // Create a connection for the server, using Node's IPC as a transport. 27 | const connection = createConnection(ProposedFeatures.all); 28 | connection.sendNotification('soql/validate', 'createConnection'); 29 | 30 | let runQueryValidation: boolean; 31 | 32 | // Create a simple text document manager. 33 | const documents: TextDocuments = new TextDocuments(TextDocument); 34 | 35 | connection.onInitialize((params: InitializeParams) => { 36 | runQueryValidation = QueryValidationFeature.hasRunQueryValidation(params.capabilities); 37 | connection.console.log(`runQueryValidation: ${runQueryValidation}`); 38 | const result: InitializeResult = { 39 | capabilities: { 40 | textDocumentSync: TextDocumentSyncKind.Full, // sync full document for now 41 | completionProvider: { 42 | // resolveProvider: true, 43 | triggerCharacters: [' '], 44 | }, 45 | }, 46 | }; 47 | return result; 48 | }); 49 | 50 | function clearDiagnostics(uri: DocumentUri): void { 51 | connection.sendDiagnostics({ uri, diagnostics: [] }); 52 | } 53 | 54 | documents.onDidClose((change) => { 55 | clearDiagnostics(change.document.uri); 56 | }); 57 | 58 | /** 59 | * NOTE: Listening on deleted files should NOT be necessary to trigger the clearing of Diagnostics, 60 | * since the `documents.onDidClose()` callback should take care of it. However, for some reason, 61 | * on automated tests of the SOQL VS Code extension, the 'workbench.action.close*Editor' commands 62 | * don't trigger the `onDidClose()` callback on the language server side. 63 | * 64 | * So, to be safe (and to make tests green) we explicitly clear diagnostics also on deleted files: 65 | */ 66 | connection.onDidChangeWatchedFiles((watchedFiles: DidChangeWatchedFilesParams) => { 67 | const deletedUris = watchedFiles.changes 68 | .filter((change) => change.type === FileChangeType.Deleted) 69 | .map((change) => change.uri); 70 | deletedUris.forEach(clearDiagnostics); 71 | }); 72 | 73 | documents.onDidChangeContent(async (change) => { 74 | const diagnostics = Validator.validateSoqlText(change.document); 75 | // clear syntax errors immediatly (don't wait on http call) 76 | connection.sendDiagnostics({ uri: change.document.uri, diagnostics }); 77 | 78 | if (diagnostics.length === 0 && runQueryValidation) { 79 | const remoteDiagnostics = await Validator.validateLimit0Query(change.document, connection); 80 | if (remoteDiagnostics.length > 0) { 81 | connection.sendDiagnostics({ uri: change.document.uri, diagnostics: remoteDiagnostics }); 82 | } 83 | } 84 | }); 85 | 86 | connection.onCompletion( 87 | // eslint-disable-next-line @typescript-eslint/require-await 88 | async (request: TextDocumentPositionParams): Promise => { 89 | const doc = documents.get(request.textDocument.uri); 90 | if (!doc) return []; 91 | 92 | return completionsFor(doc.getText(), request.position.line + 1, request.position.character + 1); 93 | } 94 | ); 95 | documents.listen(connection); 96 | 97 | connection.listen(); 98 | -------------------------------------------------------------------------------- /src/validator.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { TextDocument } from 'vscode-languageserver-textdocument'; 9 | import { Connection, RemoteConsole } from 'vscode-languageserver'; 10 | import { Validator, RunQueryErrorResponse, RunQuerySuccessResponse } from './validator'; 11 | 12 | function mockSOQLDoc(content: string): TextDocument { 13 | return TextDocument.create('some-uri', 'soql', 0.1, content); 14 | } 15 | 16 | function createMockClientConnection( 17 | response: { result: RunQuerySuccessResponse } | { error: RunQueryErrorResponse } 18 | ): Connection { 19 | return { 20 | // @ts-expect-error: just for testing 21 | sendRequest: ( 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | method: string, 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/no-explicit-any 25 | params: any 26 | ): { result: RunQuerySuccessResponse } | { error: RunQueryErrorResponse } => response, 27 | // eslint-disable-next-line no-console,@typescript-eslint/no-unsafe-assignment 28 | console: { log: console.log } as RemoteConsole, 29 | }; 30 | } 31 | 32 | describe('Validator', () => { 33 | describe('validateSoqlText', () => { 34 | it('empty diagnostics for a valid SOQL query', () => { 35 | const diagnostics = Validator.validateSoqlText(mockSOQLDoc('SeLeCt Id FrOm Account Ac')); 36 | expect(diagnostics.length).toEqual(0); 37 | }); 38 | it('populated diagnostics for a SOQL query with errors', () => { 39 | const diagnostics = Validator.validateSoqlText(mockSOQLDoc('SeLeCt Id FrOm')); 40 | expect(diagnostics.length).toEqual(1); 41 | }); 42 | }); 43 | 44 | describe('validateLimit0Query', () => { 45 | it('empty diagnostics for a valid SOQL query', async () => { 46 | const diagnostics = await Validator.validateLimit0Query( 47 | mockSOQLDoc('SELECT Id FROM Account'), 48 | createMockClientConnection({ 49 | result: { 50 | done: true, 51 | records: [], 52 | totalSize: 0, 53 | }, 54 | }) 55 | ); 56 | expect(diagnostics.length).toEqual(0); 57 | }); 58 | 59 | it('creates diagnostic with range when location and cause are returned from API', async () => { 60 | const serverError = "Oh Snap!\nERROR at Row:1:Column:8\nBlame 'Ids' not 'Me'"; 61 | const expectedErrorWithoutLineColumn = "Oh Snap!\nError:\nBlame 'Ids' not 'Me'"; 62 | const diagnostics = await Validator.validateLimit0Query( 63 | mockSOQLDoc('SELECT Ids FROM Account'), 64 | createMockClientConnection({ 65 | error: { 66 | name: 'INVALID_FIELD', 67 | errorCode: 'INVALID_FIELD', 68 | message: serverError, 69 | }, 70 | }) 71 | ); 72 | expect(diagnostics).toHaveLength(1); 73 | expect(diagnostics[0].message).toEqual(expectedErrorWithoutLineColumn); 74 | expect(diagnostics[0].range.start.line).toEqual(0); 75 | expect(diagnostics[0].range.start.character).toEqual(7); 76 | expect(diagnostics[0].range.end.line).toEqual(0); 77 | expect(diagnostics[0].range.end.character).toEqual(10); 78 | }); 79 | 80 | it( 81 | 'creates diagnostic with range when location and cause are returned from API' + 82 | ' when query prefixed by newlines', 83 | async () => { 84 | // The Query API seems to be "ignoring" the initial empty lines, so 85 | // it reports the error lines as starting from the first non-empty line 86 | const serverError = "Oh Snap!\nERROR at Row:1:Column:8\nBlame 'Ids' not 'Me'"; 87 | const expectedErrorWithoutLineColumn = "Oh Snap!\nError:\nBlame 'Ids' not 'Me'"; 88 | const diagnostics = await Validator.validateLimit0Query( 89 | mockSOQLDoc('\n\n// Comment here\n\nSELECT Ids FROM Account'), 90 | createMockClientConnection({ 91 | error: { 92 | name: 'INVALID_FIELD', 93 | errorCode: 'INVALID_FIELD', 94 | message: serverError, 95 | }, 96 | }) 97 | ); 98 | 99 | // The expected error line is: the number of empty lines at the top (4), 100 | // plus the reported error line number (1), minus 1 because this is zero based 101 | const errorLine = 4; 102 | 103 | expect(diagnostics).toHaveLength(1); 104 | expect(diagnostics[0].message).toEqual(expectedErrorWithoutLineColumn); 105 | expect(diagnostics[0].range.start.line).toEqual(errorLine); 106 | expect(diagnostics[0].range.start.character).toEqual(7); 107 | expect(diagnostics[0].range.end.line).toEqual(errorLine); 108 | expect(diagnostics[0].range.end.character).toEqual(10); 109 | } 110 | ); 111 | 112 | it('creates diagnostic with full doc range when location is not found', async () => { 113 | const expectedError = 'Oh Snap!'; 114 | const diagnostics = await Validator.validateLimit0Query( 115 | mockSOQLDoc('SELECT Id\nFROM Accounts'), 116 | createMockClientConnection({ 117 | error: { 118 | name: 'INVALID_TYPE', 119 | errorCode: 'INVALID_TYPE', 120 | message: expectedError, 121 | }, 122 | }) 123 | ); 124 | expect(diagnostics).toHaveLength(1); 125 | expect(diagnostics[0].message).toEqual(expectedError); 126 | expect(diagnostics[0].range.start.line).toEqual(0); 127 | expect(diagnostics[0].range.start.character).toEqual(0); 128 | expect(diagnostics[0].range.end.line).toEqual(2); // one line greater than doc length 129 | expect(diagnostics[0].range.end.character).toEqual(0); 130 | }); 131 | 132 | it('creates diagnostic message for errorCode INVALID_TYPE', async () => { 133 | const expectedError = 'Oh Snap!'; 134 | const diagnostics = await Validator.validateLimit0Query( 135 | mockSOQLDoc('SELECT Id\nFROM Accounts'), 136 | createMockClientConnection({ 137 | error: { 138 | name: 'INVALID_TYPE', 139 | errorCode: 'INVALID_TYPE', 140 | message: expectedError, 141 | }, 142 | }) 143 | ); 144 | expect(diagnostics).toHaveLength(1); 145 | expect(diagnostics[0].message).toEqual(expectedError); 146 | expect(diagnostics[0].range.start.line).toEqual(0); 147 | expect(diagnostics[0].range.start.character).toEqual(0); 148 | expect(diagnostics[0].range.end.line).toEqual(2); // one line greater than doc length 149 | expect(diagnostics[0].range.end.character).toEqual(0); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | import { SOQLParser } from '@salesforce/soql-common/lib/soql-parser'; 9 | import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver'; 10 | import { TextDocument } from 'vscode-languageserver-textdocument'; 11 | import { Connection } from 'vscode-languageserver'; 12 | import { parseHeaderComments, SoqlWithComments } from '@salesforce/soql-common/lib/soqlComments'; 13 | import { RequestTypes, RunQueryResponse } from './index'; 14 | 15 | const findLimitRegex = new RegExp(/LIMIT\s+\d+\s*$/, 'i'); 16 | const findPositionRegex = new RegExp(/ERROR at Row:(?\d+):Column:(?\d+)/); 17 | const findCauseRegex = new RegExp(/'(?\S+)'/); 18 | 19 | export interface RunQuerySuccessResponse { 20 | done: boolean; 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | records: any[]; 23 | totalSize: number; 24 | } 25 | export interface RunQueryErrorResponse { 26 | name: string; 27 | errorCode: string; 28 | message: string; 29 | } 30 | 31 | export class Validator { 32 | public static validateSoqlText(textDocument: TextDocument): Diagnostic[] { 33 | const diagnostics: Diagnostic[] = []; 34 | const parser = SOQLParser({ 35 | isApex: true, 36 | isMultiCurrencyEnabled: true, 37 | apiVersion: 50.0, 38 | }); 39 | const result = parser.parseQuery(parseHeaderComments(textDocument.getText()).headerPaddedSoqlText); 40 | if (!result.getSuccess()) { 41 | result.getParserErrors().forEach((error) => { 42 | diagnostics.push({ 43 | severity: DiagnosticSeverity.Error, 44 | range: { 45 | start: textDocument.positionAt(error.getToken()?.startIndex as number), 46 | end: textDocument.positionAt(error.getToken()?.stopIndex as number), 47 | }, 48 | message: error.getMessage(), 49 | source: 'soql', 50 | }); 51 | }); 52 | } 53 | return diagnostics; 54 | } 55 | 56 | public static async validateLimit0Query(textDocument: TextDocument, connection: Connection): Promise { 57 | connection.console.log(`validate SOQL query:\n${textDocument.getText()}`); 58 | 59 | const diagnostics: Diagnostic[] = []; 60 | const soqlWithHeaderComments = parseHeaderComments(textDocument.getText()); 61 | 62 | const response = await connection.sendRequest( 63 | RequestTypes.RunQuery, 64 | appendLimit0(soqlWithHeaderComments.soqlText) 65 | ); 66 | 67 | if (response.error) { 68 | const { errorMessage, errorRange } = extractErrorRange(soqlWithHeaderComments, response.error.message); 69 | diagnostics.push({ 70 | severity: DiagnosticSeverity.Error, 71 | range: errorRange || documentRange(textDocument), 72 | message: errorMessage, 73 | source: 'soql', 74 | }); 75 | } 76 | return diagnostics; 77 | } 78 | } 79 | 80 | function appendLimit0(query: string): string { 81 | if (findLimitRegex.test(query)) { 82 | query = query.replace(findLimitRegex, 'LIMIT 0'); 83 | } else { 84 | query = `${query} LIMIT 0`; 85 | } 86 | return query; 87 | } 88 | 89 | function extractErrorRange( 90 | soqlWithComments: SoqlWithComments, 91 | errorMessage: string 92 | ): { errorRange: Range | undefined; errorMessage: string } { 93 | const posMatch = findPositionRegex.exec(errorMessage); 94 | if (posMatch && posMatch.groups) { 95 | const line = Number(posMatch.groups.row) - 1 + soqlWithComments.commentLineCount; 96 | const character = Number(posMatch.groups.column) - 1; 97 | const causeMatch = findCauseRegex.exec(errorMessage); 98 | const cause = (causeMatch && causeMatch.groups && causeMatch.groups.cause) || ' '; 99 | return { 100 | // Strip out the line and column information from the error message 101 | errorMessage: errorMessage.replace(findPositionRegex, 'Error:'), 102 | errorRange: { 103 | start: { line, character }, 104 | end: { line, character: character + cause.length }, 105 | }, 106 | }; 107 | } else { 108 | return { errorMessage, errorRange: undefined }; 109 | } 110 | } 111 | 112 | function documentRange(textDocument: TextDocument): Range { 113 | return { 114 | start: { line: 0, character: 0 }, 115 | end: { line: textDocument.lineCount, character: 0 }, 116 | }; 117 | } 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "lib": ["es2018"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "outDir": "lib", 9 | "resolveJsonModule": true, 10 | "rootDir": "src", 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "es2018" 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["src"] 17 | } 18 | --------------------------------------------------------------------------------