├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── client └── extension.ts ├── compiler ├── editor.ts ├── editor │ ├── spec.ts │ └── workspace.ts ├── lexical-analysis │ ├── ast.ts │ ├── ast │ │ ├── tree.ts │ │ └── visitor.ts │ ├── lexer.ts │ ├── lexical.ts │ ├── lexical │ │ ├── location.ts │ │ └── static_error.ts │ └── parser.ts ├── static-analysis │ ├── analyzer.ts │ └── service.ts └── static.ts ├── images └── kube-demo.gif ├── language-configuration.json ├── package-lock.json ├── package.json ├── package.jsonnet ├── package.libsonnet ├── server ├── diagnostic.ts ├── local.ts └── server.ts ├── site ├── index.html └── main.ts ├── syntaxes ├── jsonnet.tmLanguage.json └── jsonnet.tmLanguage.jsonnet ├── test ├── data │ ├── simple-import.jsonnet │ ├── simple-import.libsonnet │ └── simple-nodes.jsonnet ├── extension.test.ts ├── index.ts └── server │ ├── analysis_tests.ts │ ├── ast │ ├── lexer_tests.ts │ ├── parser_tests.ts │ └── workspace_tests.ts │ ├── parser │ ├── completion_tests.ts │ └── resolve_tests.ts │ └── test_workspace.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that launches the extension inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "type": "node", 7 | "request": "launch", 8 | "name": "Mocha Tests", 9 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 10 | "args": [ 11 | "-u", 12 | "tdd", 13 | "--timeout", 14 | "999999", 15 | "--colors", 16 | "--recursive", 17 | "${workspaceRoot}/out/test" 18 | ], 19 | "sourceMaps": true, 20 | "outFiles": [ "${workspaceRoot}/out/**/*.js" ], 21 | "internalConsoleOptions": "openOnSessionStart", 22 | "preLaunchTask": "npm" 23 | }, 24 | { 25 | "name": "Launch Extension", 26 | "type": "extensionHost", 27 | "request": "launch", 28 | "runtimeExecutable": "${execPath}", 29 | "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], 30 | "stopOnEntry": false, 31 | "sourceMaps": true, 32 | "outFiles": [ "${workspaceRoot}/out/client/**/*.js" ], 33 | "preLaunchTask": "npm" 34 | }, 35 | { 36 | "name": "Attach to TS Server", 37 | "type": "node", 38 | "request": "attach", 39 | "port": 6009, 40 | "sourceMaps": true, 41 | "outFiles": [ "${workspaceRoot}/out/**/*.js" ], 42 | "preLaunchTask": "npm" 43 | }, 44 | { 45 | "name": "Launch Tests", 46 | "type": "extensionHost", 47 | "request": "launch", 48 | "runtimeExecutable": "${execPath}", 49 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 50 | "stopOnEntry": false, 51 | "sourceMaps": true, 52 | "outFiles": [ "${workspaceRoot}/out/test/**/*.js" ], 53 | "preLaunchTask": "npm" 54 | } 55 | ] 56 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": true, // set this to true to hide the "out" folder with the compiled JS files 5 | "node_modules": true 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "node_modules": true 10 | } 11 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "0.1.0", 12 | 13 | // we want to run npm 14 | "command": "npm", 15 | 16 | // the command is a shell script 17 | "isShellCommand": true, 18 | 19 | // show the output window only if unrecognized errors occur. 20 | "showOutput": "silent", 21 | 22 | // we run the custom script "compile" as defined in package.json 23 | "args": ["run", "compile", "--loglevel", "silent"], 24 | 25 | // The tsc compiler is started in watching mode 26 | "isBackground": true, 27 | 28 | // use the standard tsc in watch mode problem matcher to find compile problems in the output. 29 | "problemMatcher": "$tsc-watch" 30 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to the "jsonnet" extension will be documented in this file. 3 | 4 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 5 | 6 | ## [Unreleased] 7 | - Initial release -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## DCO Sign off 2 | 3 | All authors to the project retain copyright to their work. However, to ensure 4 | that they are only submitting work that they have rights to, we are requiring 5 | everyone to acknowldge this by signing their work. 6 | 7 | Any copyright notices in this repos should specify the authors as "The 8 | heptio/aws-quickstart authors". 9 | 10 | To sign your work, just add a line like this at the end of your commit message: 11 | 12 | ``` 13 | Signed-off-by: Joe Beda 14 | ``` 15 | 16 | This can easily be done with the `--signoff` option to `git commit`. 17 | 18 | By doing this you state that you can certify the following (from https://developercertificate.org/): 19 | 20 | ``` 21 | Developer Certificate of Origin 22 | Version 1.1 23 | 24 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 25 | 1 Letterman Drive 26 | Suite D4700 27 | San Francisco, CA, 94129 28 | 29 | Everyone is permitted to copy and distribute verbatim copies of this 30 | license document, but changing it is not allowed. 31 | 32 | 33 | Developer's Certificate of Origin 1.1 34 | 35 | By making a contribution to this project, I certify that: 36 | 37 | (a) The contribution was created in whole or in part by me and I 38 | have the right to submit it under the open source license 39 | indicated in the file; or 40 | 41 | (b) The contribution is based upon previous work that, to the best 42 | of my knowledge, is covered under an appropriate open source 43 | license and I have the right under that license to submit that 44 | work with modifications, whether created in whole or in part 45 | by me, under the same open source license (unless I am 46 | permitted to submit under a different license), as indicated 47 | in the file; or 48 | 49 | (c) The contribution was provided directly to me by some other 50 | person who certified (a), (b) or (c) and I have not modified 51 | it. 52 | 53 | (d) I understand and agree that this project and the contribution 54 | are public and that a record of the contribution (including all 55 | personal information I submit with it, including my sign-off) is 56 | maintained indefinitely and may be redistributed consistent with 57 | this project or the open source license(s) involved. 58 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jsonnet Support for Visual Studio Code 2 | 3 | A simple bare-bones extension providing simple syntax highlighting 4 | for [Jsonnet][jsonnet] files (specifically, files with the `.jsonnet` 5 | and `.libsonnet` suffixes), as well as a Markdown-style preview pane 6 | that auto-updates every time you save. 7 | 8 | ![Jsonnet preview][jsonnet-demo] 9 | 10 | ## Usage 11 | 12 | Syntax highlighting works out of the box. Just open any `.jsonnet` or 13 | `.libsonnet` file, and it will magically work. 14 | 15 | To enable the Jsonnet preview pane, it is necessary to install the 16 | Jsonnet command line tool (_e.g._, through `brew install jsonnet`). If 17 | you don't add the `jsonnet` executable to the `PATH` then you will 18 | need to customize `jsonnet.executablePath` in your `settings.json`, so 19 | that the extension knows where to find it. 20 | 21 | After this, you can use the keybinding for `jsonnet.previewToSide` (by 22 | default this is `shift+ctrl+i`, or `shift+cmd+i` on macOS), and the 23 | preview pane will open as in the picture above. 24 | 25 | ## Customization 26 | 27 | This extension exposes the following settings, which can be customized 28 | in `settings.json`: 29 | 30 | * `jsonnet.executablePath`: Tells the extension where to find the 31 | `jsonnet` executable, if it's not on the `PATH`. (NOTE: This setting 32 | is always necessary on Windows.) 33 | * `jsonnet.libPaths`: Additional paths to search for libraries when compiling Jsonnet code. 34 | * `jsonnet.outputFormat`: Preview output format: yaml or json (default is yaml). 35 | * `jsonnet.extStrs`: External strings to pass to `jsonnet` executable. 36 | 37 | This extension exposes the following commands, which can be bound to 38 | keys: 39 | 40 | * `jsonnet.previewToSide`: Compiles the Jsonnet file to JSON, places 41 | result in a "preview" window in the pane to the right of the active 42 | pane, or in the current pane if active window is pane 3 (since 43 | vscode only allows 3 panes). Default: bound to `shift+ctrl+i` (or 44 | `shift+cmd+i` on macOS). 45 | * `jsonnet.previewToSide`: Compiles the Jsonnet file to JSON, places 46 | result in a "preview" window in the current active pane. Default: no 47 | keybinding. 48 | * `jsonnet.extStrs`: An object of variable, value pairs. Allows you to 49 | customize the external variables passed to the `jsonnet` command 50 | line. It can be particularly useful to set this in a workspace 51 | configuration, so that you can set different variables on a 52 | per-project basis. 53 | * `jsonnet.outputFormat`: A choice of two string literals: `["json", 54 | "yaml"]`. This tells the extension what format you'd like the output 55 | to be (_i.e._, allows you to either output JSON or YAML). 56 | 57 | [jsonnet]: http://jsonnet.org/ "Jsonnet" 58 | [jsonnet-demo]: https://raw.githubusercontent.com/heptio/vscode-jsonnet/master/images/kube-demo.gif 59 | -------------------------------------------------------------------------------- /client/extension.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | import * as client from 'vscode-languageclient'; 4 | import * as os from 'os'; 5 | import * as path from 'path'; 6 | import * as fs from 'fs'; 7 | import * as vs from 'vscode'; 8 | import * as yaml from "js-yaml"; 9 | 10 | import * as im from 'immutable'; 11 | 12 | import * as lexical from '../compiler/lexical-analysis/lexical'; 13 | 14 | 15 | // activate registers the Jsonnet language server with vscode, and 16 | // configures it based on the contents of the workspace JSON file. 17 | export const activate = (context: vs.ExtensionContext) => { 18 | register.jsonnetClient(context); 19 | const diagProvider = register.diagnostics(context); 20 | register.previewCommands(context, diagProvider); 21 | } 22 | 23 | export const deactivate = () => { } 24 | 25 | namespace register { 26 | // jsonnetClient registers the Jsonnet language client with vscode. 27 | export const jsonnetClient = (context: vs.ExtensionContext): void => { 28 | // The server is implemented in node 29 | let languageClient = jsonnet.languageClient( 30 | context.asAbsolutePath(path.join('out', 'server', 'server.js'))); 31 | 32 | 33 | // Push the disposable to the context's subscriptions so that the 34 | // client can be deactivated on extension deactivation 35 | context.subscriptions.push(languageClient.start()); 36 | 37 | // Configure the workspace. 38 | workspace.configure(vs.workspace.getConfiguration('jsonnet')); 39 | } 40 | 41 | // diagnostics registers a `jsonnet.DiagnosticProvider` with vscode. 42 | // This will cause vscode to render errors and warnings for users as 43 | // they save their code. 44 | export const diagnostics = ( 45 | context: vs.ExtensionContext, 46 | ): jsonnet.DiagnosticProvider => { 47 | const diagnostics = vs.languages.createDiagnosticCollection("jsonnet"); 48 | context.subscriptions.push(diagnostics); 49 | return new jsonnet.DiagnosticProvider(diagnostics); 50 | } 51 | 52 | // previewCommands will register the commands that allow people to 53 | // open a "preview" pane that renders their Jsonnet, similar to the 54 | // markdown preview pane. 55 | export const previewCommands = ( 56 | context: vs.ExtensionContext, diagProvider: jsonnet.DiagnosticProvider, 57 | ): void => { 58 | // Create Jsonnet provider, register it to provide for documents 59 | // with `PREVIEW_SCHEME` URI scheme. 60 | const docProvider = new jsonnet.DocumentProvider(); 61 | const registration = vs.workspace.registerTextDocumentContentProvider( 62 | jsonnet.PREVIEW_SCHEME, docProvider); 63 | 64 | // Subscribe to document updates. This allows us to detect (e.g.) 65 | // when a document was saved. 66 | context.subscriptions.push(registration); 67 | 68 | // Expand Jsonnet, register errors as diagnostics with vscode, and 69 | // generate preview if a preview tab is open. 70 | const preview = (doc: vs.TextDocument): void => { 71 | if (doc.languageId === "jsonnet") { 72 | const result = docProvider.cachePreview(doc); 73 | if (jsonnet.isRuntimeFailure(result)) { 74 | diagProvider.report(doc.uri, result.error); 75 | } else { 76 | diagProvider.clear(doc.uri); 77 | } 78 | docProvider.update(jsonnet.canonicalPreviewUri(doc.uri)); 79 | } 80 | } 81 | 82 | // Register Jsonnet preview commands. 83 | context.subscriptions.push(vs.commands.registerCommand( 84 | 'jsonnet.previewToSide', () => display.previewJsonnet(true))); 85 | context.subscriptions.push(vs.commands.registerCommand( 86 | 'jsonnet.preview', () => display.previewJsonnet(false))); 87 | 88 | // Call `preview` any time we save or open a document. 89 | context.subscriptions.push(vs.workspace.onDidSaveTextDocument(preview)); 90 | context.subscriptions.push(vs.workspace.onDidOpenTextDocument(preview)); 91 | context.subscriptions.push(vs.workspace.onDidCloseTextDocument(doc => { 92 | docProvider.delete(doc); 93 | })); 94 | 95 | // Call `preview` when we open the editor. 96 | const active = vs.window.activeTextEditor; 97 | if (active != null) { 98 | preview(active.document); 99 | } 100 | } 101 | } 102 | 103 | namespace workspace { 104 | const extStrsProp = "extStrs"; 105 | const execPathProp = "executablePath"; 106 | 107 | export const extStrs = (): string => { 108 | const extStrsObj = vs.workspace.getConfiguration('jsonnet')[extStrsProp]; 109 | return extStrsObj == null 110 | ? "" 111 | : Object.keys(extStrsObj) 112 | .map(key => `--ext-str ${key}="${extStrsObj[key]}"`) 113 | .join(" "); 114 | } 115 | 116 | export const libPaths = (): string => { 117 | const libPaths = vs.workspace.getConfiguration('jsonnet')["libPaths"]; 118 | if (libPaths == null) { 119 | return ""; 120 | } 121 | 122 | // Add executable to the beginning of the library paths, because 123 | // the Jsonnet CLI will look there first. 124 | // 125 | // TODO(hausdorff): Consider adding support for Jsonnet's 126 | // (undocumented) search paths `/usr/share/{jsonnet version}` and 127 | // `/usr/local/share/{jsonnet version}`. We don't support them 128 | // currently because (1) they're undocumented and therefore not 129 | // widely-used, and (2) it requires shelling out to the Jsonnet 130 | // command line, which complicates the extension. 131 | const jsonnetExecutable = vs.workspace.getConfiguration[execPathProp]; 132 | if (jsonnetExecutable != null) { 133 | (libPaths).unshift(jsonnetExecutable); 134 | } 135 | 136 | return libPaths 137 | .map(path => `-J ${path}`) 138 | .join(" "); 139 | } 140 | 141 | export const outputFormat = (): "json" | "yaml" => { 142 | return vs.workspace.getConfiguration('jsonnet')["outputFormat"]; 143 | } 144 | 145 | export const configure = (config: vs.WorkspaceConfiguration): boolean => { 146 | if (os.type() === "Windows_NT") { 147 | return configureWindows(config); 148 | } else { 149 | return configureUnix(config); 150 | } 151 | } 152 | 153 | const configureUnix = (config: vs.WorkspaceConfiguration): boolean => { 154 | if (config[execPathProp] != null) { 155 | jsonnet.executable = config[execPathProp]; 156 | } else { 157 | try { 158 | // If this doesn't throw, 'jsonnet' was found on 159 | // $PATH. 160 | // 161 | // TODO: Probably should find a good non-shell way of 162 | // doing this. 163 | execSync(`which jsonnet`); 164 | } catch (e) { 165 | alert.jsonnetCommandNotOnPath(); 166 | return false; 167 | } 168 | } 169 | 170 | return true; 171 | } 172 | 173 | const configureWindows = (config: vs.WorkspaceConfiguration): boolean => { 174 | if (config[execPathProp] == null) { 175 | alert.jsonnetCommandIsNull(); 176 | return false; 177 | } 178 | 179 | jsonnet.executable = config[execPathProp]; 180 | return true; 181 | } 182 | } 183 | 184 | namespace alert { 185 | const alert = vs.window.showErrorMessage; 186 | 187 | export const noActiveWindow = () => { 188 | alert("Can't open Jsonnet preview because there is no active window"); 189 | } 190 | 191 | export const documentNotJsonnet = (languageId) => { 192 | alert(`Can't generate Jsonnet document preview for document with language id '${languageId}'`); 193 | } 194 | 195 | export const couldNotRenderJsonnet = (reason) => { 196 | alert(`Error: Could not render Jsonnet; ${reason}`); 197 | } 198 | 199 | export const jsonnetCommandNotOnPath = () => { 200 | alert(`Error: could not find 'jsonnet' command on path`); 201 | } 202 | 203 | export const jsonnetCommandIsNull = () => { 204 | alert(`Error: 'jsonnet.executablePath' must be set in vscode settings`); 205 | } 206 | } 207 | 208 | namespace html { 209 | export const body = (body: string): string => { 210 | return `${body}` 211 | } 212 | 213 | export const codeLiteral = (code: string): string => { 214 | return `
${code}
` 215 | } 216 | 217 | export const errorMessage = (message: string): string => { 218 | return `
${message}
`; 219 | } 220 | 221 | export const prettyPrintObject = ( 222 | json: string, outputFormat: "json" | "yaml" 223 | ): string => { 224 | if (outputFormat == "yaml") { 225 | return codeLiteral(yaml.safeDump(JSON.parse(json))); 226 | } else { 227 | return codeLiteral(JSON.stringify(JSON.parse(json), null, 4)); 228 | } 229 | } 230 | } 231 | 232 | namespace jsonnet { 233 | export let executable = "jsonnet"; 234 | export const PREVIEW_SCHEME = "jsonnet-preview"; 235 | export const DOCUMENT_FILTER = { 236 | language: 'jsonnet', 237 | scheme: 'file' 238 | }; 239 | 240 | export const languageClient = (serverModule: string) => { 241 | // The debug options for the server 242 | let debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] }; 243 | 244 | // If the extension is launched in debug mode then the debug 245 | // server options are used. Otherwise the run options are used 246 | let serverOptions: client.ServerOptions = { 247 | run: { 248 | module: serverModule, 249 | transport: client.TransportKind.ipc, 250 | }, 251 | debug: { 252 | module: serverModule, 253 | transport: client.TransportKind.ipc, 254 | options: debugOptions 255 | } 256 | } 257 | 258 | // Options to control the language client 259 | let clientOptions: client.LanguageClientOptions = { 260 | // Register the server for plain text documents 261 | documentSelector: [jsonnet.DOCUMENT_FILTER.language], 262 | synchronize: { 263 | // Synchronize the workspace/user settings sections 264 | // prefixed with 'jsonnet' to the server. 265 | configurationSection: DOCUMENT_FILTER.language, 266 | // Notify the server about file changes to '.clientrc 267 | // files contain in the workspace. 268 | fileEvents: vs.workspace.createFileSystemWatcher('**/.clientrc') 269 | } 270 | } 271 | 272 | // Create the language client and start the client. 273 | return new client.LanguageClient( 274 | "JsonnetLanguageServer", 275 | 'Jsonnet Language Server', 276 | serverOptions, 277 | clientOptions); 278 | } 279 | 280 | export const canonicalPreviewUri = (fileUri: vs.Uri) => { 281 | return fileUri.with({ 282 | scheme: jsonnet.PREVIEW_SCHEME, 283 | path: `${fileUri.path}.rendered`, 284 | query: fileUri.toString(), 285 | }); 286 | } 287 | 288 | export const fileUriFromPreviewUri = (previewUri: vs.Uri): vs.Uri => { 289 | const file = previewUri.fsPath.slice(0, -(".rendered".length)); 290 | return vs.Uri.file(file); 291 | } 292 | 293 | // RuntimeError represents a runtime failure in a Jsonnet program. 294 | export class RuntimeFailure { 295 | constructor( 296 | readonly error: string, 297 | ) { } 298 | } 299 | 300 | export const isRuntimeFailure = (thing): thing is RuntimeFailure => { 301 | return thing instanceof RuntimeFailure; 302 | } 303 | 304 | // DocumentProvider compiles Jsonnet code to JSON or YAML, and 305 | // provides that to vscode for rendering in the preview pane. 306 | // 307 | // DESIGN NOTES: This class optionally exposes `cachePreview` and 308 | // `delete` so that the caller can get the results of the document 309 | // compilation for purposes of (e.g.) reporting diagnostic issues. 310 | export class DocumentProvider implements vs.TextDocumentContentProvider { 311 | public provideTextDocumentContent = ( 312 | previewUri: vs.Uri, 313 | ): Thenable => { 314 | const sourceUri = vs.Uri.parse(previewUri.query); 315 | return vs.workspace.openTextDocument(sourceUri) 316 | .then(sourceDoc => { 317 | const result = this.previewCache.has(sourceUri.toString()) 318 | ? this.previewCache.get(sourceUri.toString()) 319 | : this.cachePreview(sourceDoc); 320 | if (isRuntimeFailure(result)) { 321 | return html.body(html.errorMessage(result.error)); 322 | } 323 | const outputFormat = workspace.outputFormat(); 324 | return html.body(html.prettyPrintObject(result, outputFormat)); 325 | }); 326 | } 327 | 328 | public cachePreview = (sourceDoc: vs.TextDocument): RuntimeFailure | string => { 329 | const sourceUri = sourceDoc.uri.toString(); 330 | const sourceFile = sourceDoc.uri.fsPath 331 | 332 | let codePaths = ''; 333 | 334 | if (ksonnet.isInApp(sourceFile)) { 335 | const dir = path.dirname(sourceFile); 336 | const paramsPath = path.join(dir, "params.libsonnet"); 337 | const rootDir = ksonnet.rootPath(sourceFile); 338 | const envParamsPath = path.join(rootDir, "environments", "default", "params.libsonnet"); 339 | 340 | let codeImports = { 341 | '__ksonnet/params': path.join(dir, "params.libsonnet"), 342 | '__ksonnet/environments': envParamsPath, 343 | }; 344 | 345 | codePaths = Object.keys(codeImports) 346 | .map(k => `--ext-code-file "${k}"=${codeImports[k]}`) 347 | .join(' '); 348 | 349 | console.log(codePaths); 350 | } 351 | 352 | try { 353 | // Compile the preview Jsonnet file. 354 | const extStrs = workspace.extStrs(); 355 | const libPaths = workspace.libPaths(); 356 | const jsonOutput = execSync( 357 | `${jsonnet.executable} ${libPaths} ${extStrs} ${codePaths} ${sourceFile}` 358 | ).toString(); 359 | 360 | // Cache. 361 | this.previewCache = this.previewCache.set(sourceUri, jsonOutput); 362 | 363 | return jsonOutput; 364 | } catch (e) { 365 | const failure = new RuntimeFailure(e.message); 366 | this.previewCache = this.previewCache.set(sourceUri, failure); 367 | return failure; 368 | } 369 | } 370 | 371 | public delete = (document: vs.TextDocument): void => { 372 | const previewUri = document.uri.query.toString(); 373 | this.previewCache = this.previewCache.delete(previewUri); 374 | } 375 | 376 | // 377 | // Document update API. 378 | // 379 | 380 | get onDidChange(): vs.Event { 381 | return this._onDidChange.event; 382 | } 383 | 384 | public update = (uri: vs.Uri) => { 385 | this._onDidChange.fire(uri); 386 | } 387 | 388 | // 389 | // Private members. 390 | // 391 | 392 | private _onDidChange = new vs.EventEmitter(); 393 | private previewCache = im.Map(); 394 | } 395 | 396 | // DiagnosticProvider will consume the output of the Jsonnet CLI and 397 | // either (1) report diagnostics issues (e.g., errors, warnings) to 398 | // the user, or (2) clear them if the compilation was successful. 399 | export class DiagnosticProvider { 400 | constructor(private readonly diagnostics: vs.DiagnosticCollection) { } 401 | 402 | public report = (fileUri: vs.Uri, message: string): void => { 403 | const messageLines = im.List((message).split(os.EOL)).rest(); 404 | 405 | // Start over. 406 | this.diagnostics.clear(); 407 | const errorMessage = messageLines.get(0); 408 | 409 | if (errorMessage.startsWith(lexical.staticErrorPrefix)) { 410 | return this.reportStaticErrorDiagnostics(errorMessage); 411 | } else if (errorMessage.startsWith(lexical.runtimeErrorPrefix)) { 412 | const stackTrace = messageLines.rest().toList(); 413 | return this.reportRuntimeErrorDiagnostics( 414 | fileUri, errorMessage, stackTrace); 415 | } 416 | } 417 | 418 | public clear = (fileUri: vs.Uri): void => { 419 | this.diagnostics.delete(fileUri); 420 | } 421 | 422 | // 423 | // Private members. 424 | // 425 | 426 | private reportStaticErrorDiagnostics = (message: string): void => { 427 | const staticError = message.slice(lexical.staticErrorPrefix.length); 428 | const match = DiagnosticProvider.fileFromStackFrame(staticError); 429 | if (match == null) { 430 | console.log(`Could not parse filename from Jsonnet error: '${message}'`); 431 | return; 432 | } 433 | 434 | const locAndMessage = staticError.slice(match.fullMatch.length); 435 | const range = DiagnosticProvider.parseRange(locAndMessage); 436 | if (range == null) { 437 | console.log(`Could not parse location range from Jsonnet error: '${message}'`); 438 | return; 439 | } 440 | const diag = new vs.Diagnostic( 441 | range, locAndMessage, vs.DiagnosticSeverity.Error); 442 | this.diagnostics.set(vs.Uri.file(match.file), [diag]); 443 | } 444 | 445 | private reportRuntimeErrorDiagnostics = ( 446 | fileUri: vs.Uri, message: string, messageLines: im.List, 447 | ): void => { 448 | const diagnostics = messageLines 449 | .reduce((acc: im.Map>, line: string) => { 450 | // Filter error lines that we know aren't stack frames. 451 | const trimmed = line.trim(); 452 | if (trimmed == "" || trimmed.startsWith("During manifestation")) { 453 | return acc; 454 | } 455 | 456 | // Log when we think a line is a stack frame, but we can't 457 | // parse it. 458 | const match = DiagnosticProvider.fileFromStackFrame(line); 459 | if (match == null) { 460 | console.log(`Could not parse filename from Jsonnet error: '${line}'`); 461 | return acc; 462 | } 463 | 464 | const loc = line.slice(match.fileWithLeadingWhitespace.length); 465 | const range = DiagnosticProvider.parseRange(loc); 466 | if (range == null) { 467 | console.log(`Could not parse filename from Jsonnet error: '${line}'`); 468 | return acc; 469 | } 470 | 471 | // Generate and emit diagnostics. 472 | const diag = new vs.Diagnostic( 473 | range, `${message}`, vs.DiagnosticSeverity.Error); 474 | 475 | const prev = acc.get(match.file, undefined); 476 | return prev == null 477 | ? acc.set(match.file, im.List([diag])) 478 | : acc.set(match.file, prev.push(diag)); 479 | }, 480 | im.Map>()); 481 | 482 | const fileDiags = diagnostics.get(fileUri.fsPath, undefined); 483 | fileDiags != null && this.diagnostics.set(fileUri, fileDiags.toArray()); 484 | } 485 | 486 | private static parseRange = (range: string): vs.Range | null => { 487 | const lr = lexical.LocationRange.fromString("Dummy name", range); 488 | if (lr == null) { 489 | return null; 490 | } 491 | 492 | const start = new vs.Position(lr.begin.line - 1, lr.begin.column - 1); 493 | // NOTE: Don't subtract 1 from `lr.end.column` because the range 494 | // is exclusive at the end. 495 | const end = new vs.Position(lr.end.line - 1, lr.end.column); 496 | 497 | return new vs.Range(start, end); 498 | } 499 | 500 | private static fileFromStackFrame = ( 501 | frameMessage: string 502 | ): { fullMatch: string, fileWithLeadingWhitespace: string, file: string } | null => { 503 | const fileMatch = frameMessage.match(/(\s*)(.*?):/); 504 | return fileMatch == null 505 | ? null 506 | : { 507 | fullMatch: fileMatch[0], 508 | fileWithLeadingWhitespace: fileMatch[1] + fileMatch[2], 509 | file: fileMatch[2], 510 | } 511 | } 512 | } 513 | } 514 | 515 | namespace display { 516 | export const previewJsonnet = (sideBySide: boolean) => { 517 | const editor = vs.window.activeTextEditor; 518 | if (editor == null) { 519 | alert.noActiveWindow(); 520 | return; 521 | } 522 | 523 | const languageId = editor.document.languageId; 524 | if (!(editor.document.languageId === "jsonnet")) { 525 | alert.documentNotJsonnet(languageId); 526 | return; 527 | } 528 | 529 | const previewUri = jsonnet.canonicalPreviewUri(editor.document.uri); 530 | 531 | return vs.commands.executeCommand( 532 | 'vscode.previewHtml', 533 | previewUri, 534 | getViewColumn(sideBySide), 535 | `Jsonnet preview '${path.basename(editor.document.fileName)}'` 536 | ).then((success) => { }, (reason) => { 537 | alert.couldNotRenderJsonnet(reason); 538 | }); 539 | } 540 | 541 | export const getViewColumn = ( 542 | sideBySide: boolean 543 | ): vs.ViewColumn | undefined => { 544 | const active = vs.window.activeTextEditor; 545 | if (!active) { 546 | return vs.ViewColumn.One; 547 | } 548 | 549 | if (!sideBySide) { 550 | return active.viewColumn; 551 | } 552 | 553 | switch (active.viewColumn) { 554 | case vs.ViewColumn.One: 555 | return vs.ViewColumn.Two; 556 | case vs.ViewColumn.Two: 557 | return vs.ViewColumn.Three; 558 | } 559 | 560 | return active.viewColumn; 561 | } 562 | } 563 | 564 | export namespace ksonnet { 565 | // find the root of the components structure. 566 | export function isInApp(filePath: string, fsRoot = '/'): boolean { 567 | const currentPath = path.join(fsRoot, filePath) 568 | return checkForKsonnet(currentPath); 569 | } 570 | 571 | export function rootPath(filePath: string, fsRoot = '/'): string { 572 | const currentPath = path.join(fsRoot, filePath) 573 | return findRootPath(currentPath); 574 | } 575 | 576 | function checkForKsonnet(filePath: string): boolean { 577 | if (filePath === "/") { 578 | return false; 579 | } 580 | 581 | const dir = path.dirname(filePath); 582 | const parts = dir.split(path.sep) 583 | if (parts[parts.length - 1] === "components") { 584 | const root = path.dirname(dir); 585 | const ksConfig = path.join(root, "app.yaml") 586 | 587 | try { 588 | const stats = fs.statSync(ksConfig) 589 | return true; 590 | } 591 | catch (err) { 592 | return false; 593 | } 594 | } 595 | 596 | return checkForKsonnet(dir); 597 | } 598 | 599 | function findRootPath(filePath: string): string { 600 | if (filePath === "/") { 601 | return ''; 602 | } 603 | 604 | const dir = path.dirname(filePath); 605 | const parts = dir.split(path.sep) 606 | if (parts[parts.length - 1] === "components") { 607 | const root = path.dirname(dir); 608 | const ksConfig = path.join(root, "app.yaml") 609 | 610 | try { 611 | const stats = fs.statSync(ksConfig) 612 | return root; 613 | } 614 | catch (err) { 615 | return ''; 616 | } 617 | } 618 | 619 | return findRootPath(dir); 620 | } 621 | } -------------------------------------------------------------------------------- /compiler/editor.ts: -------------------------------------------------------------------------------- 1 | export * from './editor/spec'; 2 | export * from './editor/workspace'; 3 | -------------------------------------------------------------------------------- /compiler/editor/spec.ts: -------------------------------------------------------------------------------- 1 | import * as lexer from '../lexical-analysis/lexer'; 2 | import * as lexical from '../lexical-analysis/lexical'; 3 | 4 | // UiEventListener listens to events emitted by the UI that a user 5 | // interacts with, and responds to those events with (e.g.) completion 6 | // suggestions, or information about a symbol. For example, when a 7 | // user hovers over a symbol, a `Hover` event is fired, and the 8 | // UiEventListener will dispatch that event to the hook registered 9 | // with `onHover`. 10 | export interface UiEventListener { 11 | onHover: (fileUri: string, cursorLoc: lexical.Location) => Promise 12 | onComplete: ( 13 | fileUri: string, cursorLoc: lexical.Location 14 | ) => Promise 15 | } 16 | 17 | // HoverInfo represents data we want to emit when a `Hover` event is 18 | // fired. For example, this might contain the syntax-highlighted 19 | // definition of that symbol, and whatever comments accompany it. 20 | export interface HoverInfo { 21 | contents: LanguageString | LanguageString[] 22 | } 23 | 24 | // LanguageString represents a string that is meant to be rendered 25 | // (e.g., colored) using syntax highlighting for `language`. This is 26 | // mainly used in response to a `Hover` event, when we want to show 27 | // (e.g.) the definition of a symbol, along with some documentation 28 | // about it. 29 | export interface LanguageString { 30 | language: string 31 | value: string 32 | } 33 | 34 | // CompletionType represents all the possible autocomplete 35 | // suggestions. For example, when a user `.`'s into an object, we 36 | // might suggest a `Field` that completes it. 37 | export type CompletionType = "Field" | "Variable" | "Method"; 38 | 39 | // CompletionInfo represents an auto-complete suggestion. Typically 40 | // this consists of a `label` (i.e., the suggested completion text), a 41 | // `kind` (i.e., the type of suggestion, like a file or a field), and 42 | // documentation, if any. 43 | export interface CompletionInfo { 44 | label: string 45 | kind: CompletionType 46 | documentation?: string 47 | } 48 | -------------------------------------------------------------------------------- /compiler/editor/workspace.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as url from 'url'; 3 | 4 | import * as im from 'immutable'; 5 | 6 | import * as ast from '../lexical-analysis/ast'; 7 | 8 | // DocumentEventListener listens to events emitted by a 9 | // `DocumentManager` in response to changes to documents that it 10 | // manages. For example, if a document is saved, an `Save` event 11 | // would be fired by the `DocumentManager`, and subsequently processed 12 | // by a hook registered with the `DocumentEventListener`. 13 | export interface DocumentEventListener { 14 | onDocumentOpen: (uri: string, text: string, version?: number) => void 15 | onDocumentSave: (uri: string, text: string, version?: number) => void 16 | onDocumentClose: (uri: string) => void 17 | }; 18 | 19 | export type FileUri = string; 20 | 21 | // DocumentManager typically provides 2 important pieces of 22 | // functionality: 23 | // 24 | // 1. It is the system of record for documents managed in a 25 | // "workspace"; if a document exists in the workspace, it should be 26 | // possible to `get` it by providing a `fileUri`. For example, in 27 | // the context of vscode, this should wrap an instance of 28 | // `TextDocuments`, which manages changes for all documents in a 29 | // vscode workspace. 30 | // 2. When a document that is managed by the `DocumentManager` is 31 | // changed, we should be firing off an event, so that the 32 | // `DocumentEventListener` can call the appropriate hook. This is 33 | // important, as it allows users to (e.g.) update parse caches, 34 | // which allows the client to provide efficient support for 35 | // features like autocomplete. 36 | // 37 | // IMPORTANT NOTE: Right now, this behavior is completely implicit. 38 | // This interface does not currently contain functions that express 39 | // hook registration (e.g., as `TextDocuments#onDidSave` does in 40 | // the case of vscode). This means that it is incumbent on the user 41 | // to actually implement this functionality and hook it up 42 | // correctly to the `DocumentEventListener`. 43 | export interface DocumentManager { 44 | get: ( 45 | fileSpec: FileUri | ast.Import | ast.ImportStr, 46 | ) => {text: string, version?: number, resolvedPath: string} 47 | } 48 | 49 | // LibPathResolver searches a set of library paths for a Jsonnet 50 | // library file specified either by a `FileUri`, or AST nodes of type 51 | // `Import` or `ImportStr`, returning an absolute, fully-qualified 52 | // file path if (and only if) the path exists. These "resolved" files 53 | // are represented with RFC 1630/1738-compliant file URIs, which can 54 | // be consumed by (e.g.) vscode. 55 | // 56 | // LibPathResolver is an abstract representation of the problem of 57 | // searching a set of paths for a Jsonnet, in the sense that only the 58 | // search logic is specified, independent of any specific filesystem. 59 | // All filesystem-specific logic (either real or an FS mock) must be 60 | // specified by implementing `LibPathResolver#pathExists`. 61 | export abstract class LibPathResolver { 62 | private readonly wd = path.resolve("."); 63 | private _libPaths = im.List([this.wd]); 64 | 65 | set libPaths(libPaths: im.List) { 66 | this._libPaths = im.List([this.wd]) 67 | .concat(libPaths) 68 | .toList(); 69 | } 70 | 71 | get libPaths(): im.List { 72 | return this._libPaths; 73 | } 74 | 75 | public resolvePath = ( 76 | fileSpec: FileUri | ast.Import | ast.ImportStr, 77 | ): url.Url | null => { 78 | // IMPLEMENTATION NOTE: We're using the RFC 1630/1738 file URI 79 | // specification for specifying files, largely because that's what 80 | // vscode expects. Specifically, we use the `file:///${filename}` 81 | // pattern rather than the `file://localhost/${filename}` pattern. 82 | 83 | let importPath: string | null = null; 84 | if (ast.isImport(fileSpec) || ast.isImportStr(fileSpec)) { 85 | if (path.isAbsolute(fileSpec.file)) { 86 | // If path is absolute and exists, it's resolved. 87 | importPath = this.pathExists(fileSpec.file) 88 | ? `file://${fileSpec.file}` 89 | : null; 90 | } else { 91 | // Else the `import` path is either: 92 | // 93 | // 1. relative to the file that's doing the importing, or 94 | // 2. relative to one of the `libPaths`. 95 | // 96 | // If neither is true, fail. 97 | // 98 | // TODO(hausdorff): I think this might be a bug. The filename 99 | // might not be relative to workspace root, in which case 100 | // `resolve` seems like it should fail. 101 | const pathToImportedFile = 102 | path.dirname(path.resolve(fileSpec.loc.fileName)); 103 | 104 | const paths = im.List([pathToImportedFile]) 105 | .concat(this.libPaths) 106 | .toList(); 107 | 108 | importPath = this.searchPaths(fileSpec.file, paths); 109 | 110 | // NOTE: Failing to set `importPath` at this point will cause 111 | // us to return `null` below. 112 | } 113 | } else { 114 | // NOTE: No need to convert to URI, it was passed in as 115 | // `FileUri`. 116 | importPath = fileSpec; 117 | } 118 | 119 | if (importPath == null) { 120 | return null; 121 | } 122 | 123 | const parsed = url.parse(importPath); 124 | if (!parsed || !parsed.path || parsed.protocol !== "file:") { 125 | throw new Error(`INTERNAL ERROR: Failed to parse URI '${fileSpec}'`); 126 | } 127 | return parsed; 128 | } 129 | 130 | //--------------------------------------------------------------------------- 131 | // Protected members. 132 | //--------------------------------------------------------------------------- 133 | 134 | protected pathExists: (path: string) => boolean; 135 | 136 | //--------------------------------------------------------------------------- 137 | // Private members. 138 | //--------------------------------------------------------------------------- 139 | 140 | private searchPaths = ( 141 | importPath: string, paths: im.List, 142 | ): FileUri | null => { 143 | for (let libPath of paths.toArray()) { 144 | try { 145 | const resolvedPath = path.join(libPath, importPath); 146 | if (this.pathExists(resolvedPath)) { 147 | return path.isAbsolute(resolvedPath) 148 | ? `file://${resolvedPath}` 149 | : `file:///${resolvedPath}`; 150 | } 151 | } catch (err) { 152 | // Ignore. 153 | } 154 | } 155 | 156 | return null; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /compiler/lexical-analysis/ast.ts: -------------------------------------------------------------------------------- 1 | export * from "./ast/tree"; 2 | export * from "./ast/visitor"; 3 | -------------------------------------------------------------------------------- /compiler/lexical-analysis/ast/visitor.ts: -------------------------------------------------------------------------------- 1 | import * as server from 'vscode-languageserver'; 2 | 3 | import * as im from 'immutable'; 4 | 5 | import * as lexical from '../lexical'; 6 | import * as tree from './tree'; 7 | 8 | export interface Visitor { 9 | visit(): void 10 | } 11 | 12 | export abstract class VisitorBase implements Visitor { 13 | protected rootObject: tree.Node | null = null; 14 | 15 | constructor( 16 | protected rootNode: tree.Node, 17 | private parent: tree.Node | null = null, 18 | private env: tree.Environment = tree.emptyEnvironment, 19 | ) {} 20 | 21 | public visit = () => { 22 | this.visitHelper(this.rootNode, this.parent, this.env); 23 | } 24 | 25 | protected visitHelper = ( 26 | node: tree.Node, parent: tree.Node | null, currEnv: tree.Environment 27 | ): void => { 28 | if (node == null) { 29 | throw Error("INTERNAL ERROR: Can't visit a null node"); 30 | } 31 | 32 | this.previsit(node, parent, currEnv); 33 | 34 | switch(node.type) { 35 | case "CommentNode": { 36 | this.visitComment(node); 37 | return; 38 | } 39 | case "CompSpecNode": { 40 | const castedNode = node; 41 | this.visitCompSpec(castedNode); 42 | castedNode.varName && this.visitHelper(castedNode.varName, castedNode, currEnv); 43 | this.visitHelper(castedNode.expr, castedNode, currEnv); 44 | return; 45 | } 46 | case "ApplyNode": { 47 | const castedNode = node; 48 | this.visitApply(castedNode); 49 | this.visitHelper(castedNode.target, castedNode, currEnv); 50 | castedNode.args.forEach((arg: tree.Node) => { 51 | this.visitHelper(arg, castedNode, currEnv); 52 | }); 53 | return; 54 | } 55 | case "ApplyBraceNode": { 56 | const castedNode = node; 57 | this.visitApplyBrace(castedNode); 58 | this.visitHelper(castedNode.left, castedNode, currEnv); 59 | this.visitHelper(castedNode.right, castedNode, currEnv); 60 | return; 61 | } 62 | case "ApplyParamAssignmentNode": { 63 | const castedNode = node; 64 | this.visitApplyParamAssignmentNode(castedNode); 65 | this.visitHelper(castedNode.right, castedNode, currEnv); 66 | return; 67 | } 68 | case "ArrayNode": { 69 | const castedNode = node; 70 | this.visitArray(castedNode); 71 | castedNode.headingComment && this.visitHelper( 72 | castedNode.headingComment, castedNode, currEnv); 73 | castedNode.elements.forEach((e: tree.Node) => { 74 | this.visitHelper(e, castedNode, currEnv); 75 | }); 76 | castedNode.trailingComment && this.visitHelper( 77 | castedNode.trailingComment, castedNode, currEnv); 78 | return; 79 | } 80 | case "ArrayCompNode": { 81 | const castedNode = node; 82 | this.visitArrayComp(castedNode); 83 | this.visitHelper(castedNode.body, castedNode, currEnv); 84 | castedNode.specs.forEach((spec: tree.CompSpec) => 85 | this.visitHelper(spec, castedNode, currEnv)); 86 | return; 87 | } 88 | case "AssertNode": { 89 | const castedNode = node; 90 | this.visitAssert(castedNode); 91 | this.visitHelper(castedNode.cond, castedNode, currEnv); 92 | castedNode.message && this.visitHelper( 93 | castedNode.message, castedNode, currEnv); 94 | this.visitHelper(castedNode.rest, castedNode, currEnv); 95 | return; 96 | } 97 | case "BinaryNode": { 98 | const castedNode = node; 99 | this.visitBinary(castedNode); 100 | this.visitHelper(castedNode.left, castedNode, currEnv); 101 | this.visitHelper(castedNode.right, castedNode, currEnv); 102 | return; 103 | } 104 | case "BuiltinNode": { 105 | const castedNode = node; 106 | this.visitBuiltin(castedNode); 107 | return; 108 | } 109 | case "ConditionalNode": { 110 | const castedNode = node; 111 | this.visitConditional(castedNode); 112 | this.visitHelper(castedNode.cond, castedNode, currEnv); 113 | this.visitHelper(castedNode.branchTrue, castedNode, currEnv); 114 | castedNode.branchFalse && this.visitHelper( 115 | castedNode.branchFalse, castedNode, currEnv); 116 | return; 117 | } 118 | case "DollarNode": { 119 | const castedNode = node; 120 | this.visitDollar(castedNode); 121 | return; 122 | } 123 | case "ErrorNode": { 124 | const castedNode = node; 125 | this.visitError(castedNode); 126 | this.visitHelper(castedNode.expr, castedNode, currEnv); 127 | return; 128 | } 129 | case "FunctionNode": { 130 | const castedNode = node; 131 | this.visitFunction(castedNode); 132 | 133 | if (castedNode.headingComment != null) { 134 | this.visitHelper(castedNode.headingComment, castedNode, currEnv); 135 | } 136 | 137 | // Add params to environment before visiting body. 138 | const envWithParams = currEnv.merge( 139 | tree.envFromParams(castedNode.parameters)); 140 | 141 | castedNode.parameters.forEach((param: tree.FunctionParam) => { 142 | this.visitHelper(param, castedNode, envWithParams); 143 | }); 144 | 145 | // Visit body. 146 | this.visitHelper(castedNode.body, castedNode, envWithParams); 147 | castedNode.trailingComment.forEach((comment: tree.Comment) => { 148 | // NOTE: Using `currEnv` instead of `envWithparams`. 149 | this.visitHelper(comment, castedNode, currEnv); 150 | }); 151 | return; 152 | } 153 | case "FunctionParamNode": { 154 | const castedNode = node; 155 | castedNode.defaultValue && this.visitHelper( 156 | castedNode.defaultValue, castedNode, currEnv); 157 | return; 158 | } 159 | case "IdentifierNode": { 160 | this.visitIdentifier(node); 161 | return; 162 | } 163 | case "ImportNode": { 164 | this.visitImport(node); 165 | return; 166 | } 167 | case "ImportStrNode": { 168 | this.visitImportStr(node); 169 | return; 170 | } 171 | case "IndexNode": { 172 | const castedNode = node; 173 | this.visitIndex(castedNode); 174 | castedNode.id != null && this.visitHelper(castedNode.id, castedNode, currEnv); 175 | castedNode.target != null && this.visitHelper( 176 | castedNode.target, castedNode, currEnv); 177 | castedNode.index != null && this.visitHelper( 178 | castedNode.index, castedNode, currEnv); 179 | return; 180 | } 181 | case "LocalBindNode": { 182 | const castedNode = node; 183 | this.visitLocalBind(node); 184 | 185 | // NOTE: If `functionSugar` is false, the params will be 186 | // empty. 187 | const envWithParams = currEnv.merge( 188 | tree.envFromParams(castedNode.params)); 189 | 190 | castedNode.params.forEach((param: tree.FunctionParam) => { 191 | this.visitHelper(param, castedNode, envWithParams) 192 | }); 193 | 194 | this.visitHelper(castedNode.body, castedNode, envWithParams); 195 | return; 196 | } 197 | case "LocalNode": { 198 | const castedNode = node; 199 | this.visitLocal(castedNode); 200 | 201 | // NOTE: The binds of a `local` are in scope for both the 202 | // binds themselves, as well as the body of the `local`. 203 | const envWithBinds = currEnv.merge(tree.envFromLocalBinds(castedNode)); 204 | castedNode.env = envWithBinds; 205 | 206 | castedNode.binds.forEach((bind: tree.LocalBind) => { 207 | this.visitHelper(bind, castedNode, envWithBinds); 208 | }); 209 | 210 | this.visitHelper(castedNode.body, castedNode, envWithBinds); 211 | return; 212 | } 213 | case "LiteralBooleanNode": { 214 | const castedNode = node; 215 | this.visitLiteralBoolean(castedNode); 216 | return; 217 | } 218 | case "LiteralNullNode": { 219 | const castedNode = node; 220 | this.visitLiteralNull(castedNode); 221 | return; 222 | } 223 | case "LiteralNumberNode": { return this.visitLiteralNumber(node); } 224 | case "LiteralStringNode": { 225 | const castedNode = node; 226 | this.visitLiteralString(castedNode); 227 | return; 228 | } 229 | case "ObjectFieldNode": { 230 | const castedNode = node; 231 | this.visitObjectField(castedNode); 232 | 233 | // NOTE: If `methodSugar` is false, the params will be empty. 234 | let envWithParams = currEnv.merge(tree.envFromParams(castedNode.ids)); 235 | 236 | castedNode.id != null && this.visitHelper( 237 | castedNode.id, castedNode, envWithParams); 238 | castedNode.expr1 != null && this.visitHelper( 239 | castedNode.expr1, castedNode, envWithParams); 240 | 241 | castedNode.ids.forEach((param: tree.FunctionParam) => { 242 | this.visitHelper(param, castedNode, envWithParams); 243 | }); 244 | 245 | castedNode.expr2 != null && this.visitHelper( 246 | castedNode.expr2, castedNode, envWithParams); 247 | castedNode.expr3 != null && this.visitHelper( 248 | castedNode.expr3, castedNode, envWithParams); 249 | if (castedNode.headingComments != null) { 250 | this.visitHelper(castedNode.headingComments, castedNode, currEnv); 251 | } 252 | return; 253 | } 254 | case "ObjectNode": { 255 | const castedNode = node; 256 | if (this.rootObject == null) { 257 | this.rootObject = castedNode; 258 | castedNode.rootObject = castedNode; 259 | } 260 | this.visitObject(castedNode); 261 | 262 | // `local` object fields are scoped with order-independence, 263 | // so something like this is legal: 264 | // 265 | // { 266 | // bar: {baz: foo}, 267 | // local foo = 3, 268 | // } 269 | // 270 | // Since this case requires `foo` to be in the environment of 271 | // `bar`'s body, we here collect up the `local` fields first, 272 | // create a new environment that includes them, and pass that 273 | // on to each field we visit. 274 | const envWithLocals = currEnv.merge( 275 | tree.envFromFields(castedNode.fields)); 276 | 277 | castedNode.fields.forEach((field: tree.ObjectField) => { 278 | // NOTE: If this is a `local` field, there is no need to 279 | // remove current field from environment. It is perfectly 280 | // legal to do something like `local foo = foo; foo` (though 281 | // it will cause a stack overflow). 282 | this.visitHelper(field, castedNode, envWithLocals); 283 | }); 284 | return; 285 | } 286 | case "DesugaredObjectFieldNode": { 287 | const castedNode = node; 288 | this.visitDesugaredObjectField(castedNode); 289 | this.visitHelper(castedNode.name, castedNode, currEnv); 290 | this.visitHelper(castedNode.body, castedNode, currEnv); 291 | return; 292 | } 293 | case "DesugaredObjectNode": { 294 | const castedNode = node; 295 | this.visitDesugaredObject(castedNode); 296 | castedNode.asserts.forEach((a: tree.Assert) => { 297 | this.visitHelper(a, castedNode, currEnv); 298 | }); 299 | castedNode.fields.forEach((field: tree.DesugaredObjectField) => { 300 | this.visitHelper(field, castedNode, currEnv); 301 | }); 302 | return; 303 | } 304 | case "ObjectCompNode": { 305 | const castedNode = node; 306 | this.visitObjectComp(castedNode); 307 | castedNode.specs.forEach((spec: tree.CompSpec) => { 308 | this.visitHelper(spec, castedNode, currEnv); 309 | }); 310 | castedNode.fields.forEach((field: tree.ObjectField) => { 311 | this.visitHelper(field, castedNode, currEnv); 312 | }); 313 | return; 314 | } 315 | case "ObjectComprehensionSimpleNode": { 316 | const castedNode = node; 317 | this.visitObjectComprehensionSimple(castedNode); 318 | this.visitHelper(castedNode.id, castedNode, currEnv); 319 | this.visitHelper(castedNode.field, castedNode, currEnv); 320 | this.visitHelper(castedNode.value, castedNode, currEnv); 321 | this.visitHelper(castedNode.array, castedNode, currEnv); 322 | return; 323 | } 324 | case "SelfNode": { 325 | const castedNode = node; 326 | this.visitSelf(castedNode); 327 | return; 328 | } 329 | case "SuperIndexNode": { 330 | const castedNode = node; 331 | this.visitSuperIndex(castedNode); 332 | castedNode.index && this.visitHelper(castedNode.index, castedNode, currEnv); 333 | castedNode.id && this.visitHelper(castedNode.id, castedNode, currEnv); 334 | return; 335 | } 336 | case "UnaryNode": { 337 | const castedNode = node; 338 | this.visitUnary(castedNode); 339 | this.visitHelper(castedNode.expr, castedNode, currEnv); 340 | return; 341 | } 342 | case "VarNode": { 343 | const castedNode = node; 344 | this.visitVar(castedNode); 345 | castedNode.id != null && this.visitHelper(castedNode.id, castedNode, currEnv); 346 | return 347 | } 348 | default: throw new Error( 349 | `Visitor could not traverse tree; unknown node type '${node.type}'`); 350 | } 351 | } 352 | 353 | protected previsit = ( 354 | node: tree.Node, parent: tree.Node | null, currEnv: tree.Environment 355 | ): void => {} 356 | 357 | protected visitComment = (node: tree.Comment): void => {} 358 | protected visitCompSpec = (node: tree.CompSpec): void => {} 359 | protected visitApply = (node: tree.Apply): void => {} 360 | protected visitApplyBrace = (node: tree.ApplyBrace): void => {} 361 | protected visitApplyParamAssignmentNode = (node: tree.ApplyParamAssignment): void => {} 362 | protected visitArray = (node: tree.Array): void => {} 363 | protected visitArrayComp = (node: tree.ArrayComp): void => {} 364 | protected visitAssert = (node: tree.Assert): void => {} 365 | protected visitBinary = (node: tree.Binary): void => {} 366 | protected visitBuiltin = (node: tree.Builtin): void => {} 367 | protected visitConditional = (node: tree.Conditional): void => {} 368 | protected visitDollar = (node: tree.Dollar): void => {} 369 | protected visitError = (node: tree.ErrorNode): void => {} 370 | protected visitFunction = (node: tree.Function): void => {} 371 | 372 | protected visitIdentifier = (node: tree.Identifier): void => {} 373 | protected visitImport = (node: tree.Import): void => {} 374 | protected visitImportStr = (node: tree.ImportStr): void => {} 375 | protected visitIndex = (node: tree.Index): void => {} 376 | protected visitLocalBind = (node: tree.LocalBind): void => {} 377 | protected visitLocal = (node: tree.Local): void => {} 378 | 379 | protected visitLiteralBoolean = (node: tree.LiteralBoolean): void => {} 380 | protected visitLiteralNull = (node: tree.LiteralNull): void => {} 381 | 382 | protected visitLiteralNumber = (node: tree.LiteralNumber): void => {} 383 | protected visitLiteralString = (node: tree.LiteralString): void => {} 384 | protected visitObjectField = (node: tree.ObjectField): void => {} 385 | protected visitObject = (node: tree.ObjectNode): void => {} 386 | protected visitDesugaredObjectField = (node: tree.DesugaredObjectField): void => {} 387 | protected visitDesugaredObject = (node: tree.DesugaredObject): void => {} 388 | protected visitObjectComp = (node: tree.ObjectComp): void => {} 389 | protected visitObjectComprehensionSimple = (node: tree.ObjectComprehensionSimple): void => {} 390 | protected visitSelf = (node: tree.Self): void => {} 391 | protected visitSuperIndex = (node: tree.SuperIndex): void => {} 392 | protected visitUnary = (node: tree.Unary): void => {} 393 | protected visitVar = (node: tree.Var): void => {} 394 | } 395 | 396 | // ---------------------------------------------------------------------------- 397 | // Initializing visitor. 398 | // ---------------------------------------------------------------------------- 399 | 400 | // InitializingVisitor initializes an AST by populating the `parent` 401 | // and `env` values in every node. 402 | export class InitializingVisitor extends VisitorBase { 403 | protected previsit = ( 404 | node: tree.Node, parent: tree.Node | null, currEnv: tree.Environment 405 | ): void => { 406 | node.parent = parent; 407 | node.env = currEnv; 408 | node.rootObject = this.rootObject; 409 | } 410 | } 411 | 412 | // ---------------------------------------------------------------------------- 413 | // Cursor visitor. 414 | // ---------------------------------------------------------------------------- 415 | 416 | // FindFailure represents a failure find a node whose range wraps a 417 | // cursor location. 418 | export type FindFailure = AnalyzableFindFailure | UnanalyzableFindFailure; 419 | 420 | export const isFindFailure = (thing): thing is FindFailure => { 421 | return thing instanceof UnanalyzableFindFailure || 422 | thing instanceof AnalyzableFindFailure; 423 | } 424 | 425 | export type FindFailureKind = 426 | "BeforeDocStart" | "AfterDocEnd" | "AfterLineEnd" | "NotIdentifier"; 427 | 428 | // AnalyzableFindFailure represents a failure to find a node whose 429 | // range wraps a cursor location, but which is amenable to static 430 | // analysis. 431 | // 432 | // In particular, this means that the cursor lies in the range of the 433 | // document's AST, and it is therefore possible to inspect the AST 434 | // surrounding the cursor. 435 | export class AnalyzableFindFailure { 436 | // IMPLEMENTATION NOTES: Currently we consider the kind 437 | // `"AfterDocEnd"` to be unanalyzable, but as our static analysis 438 | // features become more featureful, we can probably revisit this 439 | // corner case and get better results in the general case. 440 | 441 | constructor( 442 | public readonly kind: "AfterLineEnd" | "NotIdentifier", 443 | public readonly tightestEnclosingNode: tree.Node, 444 | public readonly terminalNodeOnCursorLine: tree.Node | null, 445 | ) {} 446 | } 447 | 448 | export const isAnalyzableFindFailure = ( 449 | thing 450 | ): thing is AnalyzableFindFailure => { 451 | return thing instanceof AnalyzableFindFailure; 452 | } 453 | 454 | // UnanalyzableFindFailrue represents a failure to find a node whose 455 | // range wraps a cursor location, and is not amenable to static 456 | // analysis. 457 | // 458 | // In particular, this means that the cursor lies outside of the range 459 | // of a document's AST, which means we cannot inspect the context of 460 | // where the cursor lies in an AST. 461 | export class UnanalyzableFindFailure { 462 | constructor(public readonly kind: "BeforeDocStart" | "AfterDocEnd") {} 463 | } 464 | 465 | export const isUnanalyzableFindFailure = ( 466 | thing 467 | ): thing is UnanalyzableFindFailure => { 468 | return thing instanceof UnanalyzableFindFailure; 469 | } 470 | 471 | // CursorVisitor finds a node whose range some cursor lies in, or the 472 | // closest node to it. 473 | export class CursorVisitor extends VisitorBase { 474 | // IMPLEMENTATION NOTES: The goal of this class is to map the corner 475 | // cases into `ast.Node | FindFailure`. Broadly, this mapping falls 476 | // into a few cases: 477 | // 478 | // * Cursor in the range of an identifier. 479 | // * Return the identifier. 480 | // * Cursor in the range of a node that is not an identifier (e.g., 481 | // number literal, multi-line object with no members, and so on). 482 | // * Return a find failure with kind `"NotIdentifier"`. 483 | // * Cursor lies inside document range, the last node on the line 484 | // of the cursor ends before the cursor's position. 485 | // * Return find failure with kind `"AfterLineEnd"`. 486 | // * Cursor lies outside document range. 487 | // * Return find failure with kind `"BeforeDocStart"` or 488 | // `"AfterDocEnd"`. 489 | 490 | constructor( 491 | private cursor: lexical.Location, 492 | root: tree.Node, 493 | ) { 494 | super(root); 495 | this.terminalNode = root; 496 | } 497 | 498 | // Identifier whose range encloses the cursor, if there is one. This 499 | // can be a multi-line node (e.g., perhaps an empty object), or a 500 | // single line node (e.g., a number literal). 501 | private enclosingNode: tree.Node | null = null; 502 | 503 | // Last node in the tree. 504 | private terminalNode: tree.Node; 505 | 506 | // Last node in the line our cursor lies on, if there is one. 507 | private terminalNodeOnCursorLine: tree.Node | null = null; 508 | 509 | get nodeAtPosition(): tree.Identifier | FindFailure { 510 | if (this.enclosingNode == null) { 511 | if (this.cursor.strictlyBeforeRange(this.rootNode.loc)) { 512 | return new UnanalyzableFindFailure("BeforeDocStart"); 513 | } else if (this.cursor.strictlyAfterRange(this.terminalNode.loc)) { 514 | return new UnanalyzableFindFailure("AfterDocEnd"); 515 | } 516 | throw new Error( 517 | "INTERNAL ERROR: No wrapping identifier was found, but node didn't lie outside of document range"); 518 | } else if (!tree.isIdentifier(this.enclosingNode)) { 519 | if ( 520 | this.terminalNodeOnCursorLine != null && 521 | this.cursor.strictlyAfterRange(this.terminalNodeOnCursorLine.loc) 522 | ) { 523 | return new AnalyzableFindFailure( 524 | "AfterLineEnd", this.enclosingNode, this.terminalNodeOnCursorLine); 525 | } 526 | return new AnalyzableFindFailure( 527 | "NotIdentifier", this.enclosingNode, this.terminalNodeOnCursorLine); 528 | } 529 | return this.enclosingNode; 530 | } 531 | 532 | protected previsit = ( 533 | node: tree.Node, parent: tree.Node | null, currEnv: tree.Environment, 534 | ): void => { 535 | const nodeEnd = node.loc.end; 536 | 537 | if (this.cursor.inRange(node.loc)) { 538 | if ( 539 | this.enclosingNode == null || 540 | node.loc.rangeIsTighter(this.enclosingNode.loc) 541 | ) { 542 | this.enclosingNode = node; 543 | } 544 | } 545 | 546 | if (nodeEnd.afterRangeOrEqual(this.terminalNode.loc)) { 547 | this.terminalNode = node; 548 | } 549 | 550 | if (nodeEnd.line === this.cursor.line) { 551 | if (this.terminalNodeOnCursorLine == null) { 552 | this.terminalNodeOnCursorLine = node; 553 | } else if (nodeEnd.afterRangeOrEqual(this.terminalNodeOnCursorLine.loc)) { 554 | this.terminalNodeOnCursorLine = node; 555 | } 556 | } 557 | } 558 | } 559 | 560 | // nodeRangeIsCloser checks whether `thisNode` is closer to `pos` than 561 | // `thatNode`. 562 | // 563 | // NOTE: Function currently works for expressions that are on one 564 | // line. 565 | const nodeRangeIsCloser = ( 566 | pos: lexical.Location, thisNode: tree.Node, thatNode: tree.Node 567 | ): boolean => { 568 | const thisLoc = thisNode.loc; 569 | const thatLoc = thatNode.loc; 570 | if (thisLoc.begin.line == pos.line && thisLoc.end.line == pos.line) { 571 | if (thatLoc.begin.line == pos.line && thatLoc.end.line == pos.line) { 572 | // `thisNode` and `thatNode` lie on the same line, and 573 | // `thisNode` begins closer to the position. 574 | // 575 | // NOTE: We use <= here so that we always choose the last node 576 | // that begins at a point. For example, a `Var` and `Identifier` 577 | // might begin in the same place, but we'd like to choose the 578 | // `Identifier`, as it would be a child of the `Var`. 579 | return Math.abs(thisLoc.begin.column - pos.column) <= 580 | Math.abs(thatLoc.begin.column - pos.column) 581 | } else { 582 | return true; 583 | } 584 | } 585 | 586 | return false; 587 | } 588 | -------------------------------------------------------------------------------- /compiler/lexical-analysis/lexical.ts: -------------------------------------------------------------------------------- 1 | export * from "./lexical/location"; 2 | export * from "./lexical/static_error"; 3 | -------------------------------------------------------------------------------- /compiler/lexical-analysis/lexical/location.ts: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////// 2 | // Location 3 | 4 | // Location represents a single location in an (unspecified) file. 5 | export class Location { 6 | constructor( 7 | readonly line: number, 8 | readonly column: number, 9 | ) {} 10 | 11 | // IsSet returns if this Location has been set. 12 | public IsSet = (): boolean => { 13 | return this.line != 0 14 | }; 15 | 16 | public String = (): string => { 17 | return `${this.line}:${this.column}`; 18 | }; 19 | 20 | public static fromString = (coord: string): Location | null => { 21 | const nums = coord.split(":"); 22 | if (nums.length != 2) { 23 | return null; 24 | } 25 | return new Location(parseInt(nums[0]), parseInt(nums[1])); 26 | } 27 | 28 | public beforeRangeOrEqual = (range: LocationRange): boolean => { 29 | const begin = range.begin; 30 | if (this.line < begin.line) { 31 | return true; 32 | } else if (this.line == begin.line && this.column <= begin.column) { 33 | return true; 34 | } 35 | return false; 36 | } 37 | 38 | public strictlyBeforeRange = (range: LocationRange): boolean => { 39 | const begin = range.begin; 40 | if (this.line < begin.line) { 41 | return true; 42 | } else if (this.line == begin.line && this.column < begin.column) { 43 | return true; 44 | } 45 | return false; 46 | } 47 | 48 | public afterRangeOrEqual = (range: LocationRange): boolean => { 49 | const end = range.end; 50 | if (this.line > end.line) { 51 | return true; 52 | } else if (this.line == end.line && this.column >= end.column) { 53 | return true; 54 | } 55 | return false; 56 | } 57 | 58 | public strictlyAfterRange = (range: LocationRange): boolean => { 59 | const end = range.end; 60 | if (this.line > end.line) { 61 | return true; 62 | } else if (this.line == end.line && this.column > end.column) { 63 | return true; 64 | } 65 | return false; 66 | } 67 | 68 | public inRange = (loc: LocationRange): boolean => { 69 | const range = { 70 | beginLine: loc.begin.line, 71 | endLine: loc.end.line, 72 | beginCol: loc.begin.column, 73 | endCol: loc.end.column, 74 | } 75 | 76 | if ( 77 | range.beginLine == this.line && this.line == range.endLine && 78 | range.beginCol <= this.column && this.column <= range.endCol 79 | ) { 80 | return true; 81 | } else if ( 82 | range.beginLine < this.line && this.line == range.endLine && 83 | this.column <= range.endCol 84 | ) { 85 | return true; 86 | } else if ( 87 | range.beginLine == this.line && this.line < range.endLine && 88 | this.column >= range.beginCol 89 | ) { 90 | return true; 91 | } else if (range.beginLine < this.line && this.line < range.endLine) { 92 | return true; 93 | } else { 94 | return false; 95 | } 96 | } 97 | } 98 | 99 | const emptyLocation = () => new Location(0, 0); 100 | 101 | ////////////////////////////////////////////////////////////////////////////// 102 | // LocationRange 103 | 104 | // LocationRange represents a range of a source file. 105 | export class LocationRange { 106 | constructor( 107 | readonly fileName: string, 108 | readonly begin: Location, 109 | readonly end: Location, 110 | ) {} 111 | 112 | // IsSet returns if this LocationRange has been set. 113 | public IsSet = (): boolean => { 114 | return this.begin.IsSet() 115 | }; 116 | 117 | public String = (): string => { 118 | if (!this.IsSet()) { 119 | return this.fileName 120 | } 121 | 122 | let filePrefix = ""; 123 | if (this.fileName.length > 0) { 124 | filePrefix = this.fileName + ":"; 125 | } 126 | if (this.begin.line == this.end.line) { 127 | if (this.begin.column == this.end.column) { 128 | return `${filePrefix}${this.begin.String()}` 129 | } 130 | return `${filePrefix}${this.begin.String()}-${this.end.column}`; 131 | } 132 | 133 | return `${filePrefix}(${this.begin.String()})-(${this.end.String()})`; 134 | } 135 | 136 | public static fromString = ( 137 | filename: string, loc: string, 138 | ): LocationRange | null => { 139 | // NOTE: Use `g` to search the string for all coordinates 140 | // formatted as `x:y`. 141 | const coordinates = loc.match(/(\d+:\d+)+/g); 142 | 143 | let start: Location | null = null; 144 | let end: Location | null = null; 145 | if (coordinates == null) { 146 | console.log(`Could not parse coordinates '${loc}'`); 147 | return null; 148 | } else if (coordinates.length == 2) { 149 | // Easy case. Of the form `(x1:y1)-(x2:y2)`. 150 | start = Location.fromString(coordinates[0]); 151 | end = Location.fromString(coordinates[1]); 152 | return start != null && end != null && new LocationRange(filename, start, end) || null; 153 | } else if (coordinates.length == 1) { 154 | // One of two forms: `x1:y1` or `x1:y1-y2`. 155 | start = Location.fromString(coordinates[0]); 156 | if (start == null) { 157 | return null; 158 | } 159 | 160 | const y2 = loc.match(/\-(\d+)/); 161 | if (y2 == null) { 162 | end = start; 163 | } else { 164 | end = new Location(start.line, parseInt(y2[1])); 165 | } 166 | 167 | return new LocationRange(filename, start, end); 168 | } else { 169 | console.log(`Could not parse coordinates '${loc}'`); 170 | return null; 171 | } 172 | } 173 | 174 | public rangeIsTighter = (thatRange: LocationRange): boolean => { 175 | return this.begin.inRange(thatRange) && this.end.inRange(thatRange); 176 | } 177 | } 178 | 179 | // This is useful for special locations, e.g. manifestation entry point. 180 | export const MakeLocationRangeMessage = (msg: string): LocationRange => { 181 | return new LocationRange(msg, emptyLocation(), emptyLocation()); 182 | } 183 | 184 | export const MakeLocationRange = ( 185 | fn: string, begin: Location, end: Location 186 | ): LocationRange => { 187 | return new LocationRange(fn, begin, end); 188 | } -------------------------------------------------------------------------------- /compiler/lexical-analysis/lexical/static_error.ts: -------------------------------------------------------------------------------- 1 | import * as ast from "../ast"; 2 | import * as location from "./location"; 3 | 4 | export const staticErrorPrefix = "STATIC ERROR: "; 5 | export const runtimeErrorPrefix = "RUNTIME ERROR: "; 6 | 7 | ////////////////////////////////////////////////////////////////////////////// 8 | // StaticError 9 | 10 | // StaticError represents an error during parsing/lexing some jsonnet. 11 | export class StaticError { 12 | constructor ( 13 | // rest allows the parser to return a partial parse result. For 14 | // example, if the user types a `.`, it is likely the document 15 | // will not parse, and it is useful to the autocomplete mechanisms 16 | // to return the AST that preceeds the `.` character. 17 | readonly rest: ast.Node | null, 18 | readonly loc: location.LocationRange, 19 | readonly msg: string, 20 | ) {} 21 | 22 | public Error = (): string => { 23 | const loc = this.loc.IsSet() 24 | ? this.loc.String() 25 | : ""; 26 | return `${loc} ${this.msg}`; 27 | } 28 | } 29 | 30 | export const isStaticError = (x: any): x is StaticError => { 31 | return x instanceof StaticError; 32 | } 33 | 34 | export const MakeStaticErrorMsg = (msg: string): StaticError => { 35 | return new StaticError(null, location.MakeLocationRangeMessage(""), msg); 36 | } 37 | 38 | export const MakeStaticErrorPoint = ( 39 | msg: string, fn: string, l: location.Location 40 | ): StaticError => { 41 | return new StaticError(null, location.MakeLocationRange(fn, l, l), msg); 42 | } 43 | 44 | export const MakeStaticError = ( 45 | msg: string, lr: location.LocationRange 46 | ): StaticError => { 47 | return new StaticError(null, lr, msg); 48 | } 49 | 50 | export const MakeStaticErrorRest = ( 51 | rest: ast.Node, msg: string, lr: location.LocationRange 52 | ): StaticError => { 53 | return new StaticError(rest, lr, msg); 54 | } 55 | -------------------------------------------------------------------------------- /compiler/static-analysis/analyzer.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as path from 'path'; 3 | 4 | import * as im from 'immutable'; 5 | 6 | import * as ast from '../lexical-analysis/ast'; 7 | import * as lexer from '../lexical-analysis/lexer'; 8 | import * as lexical from '../lexical-analysis/lexical'; 9 | import * as service from './service'; 10 | import * as editor from '../editor'; 11 | 12 | // 13 | // Analyzer. 14 | // 15 | 16 | export interface EventedAnalyzer 17 | extends editor.DocumentEventListener, editor.UiEventListener { } 18 | 19 | // TODO: Rename this to `EventedAnalyzer`. 20 | export class Analyzer implements EventedAnalyzer { 21 | constructor( 22 | private documents: editor.DocumentManager, 23 | private compilerService: service.LexicalAnalyzerService, 24 | ) { } 25 | 26 | // 27 | // WorkspaceEventListener implementation. 28 | // 29 | 30 | public onDocumentOpen = this.compilerService.cache; 31 | 32 | public onDocumentSave = this.compilerService.cache; 33 | 34 | public onDocumentClose = this.compilerService.delete; 35 | 36 | // 37 | // AnalysisEventListener implementation. 38 | // 39 | 40 | public onHover = ( 41 | fileUri: string, cursorLoc: lexical.Location 42 | ): Promise => { 43 | const emptyOnHover = Promise.resolve().then( 44 | () => { 45 | contents: [], 46 | }); 47 | 48 | const onHoverPromise = ( 49 | node: ast.Node | ast.IndexedObjectFields, 50 | ): Promise => { 51 | if (node == null) { 52 | return emptyOnHover; 53 | } 54 | 55 | try { 56 | const msg = this.renderOnhoverMessage(fileUri, node); 57 | return Promise.resolve().then( 58 | () => { 59 | contents: msg, 60 | }); 61 | } catch(err) { 62 | console.log(err); 63 | return emptyOnHover; 64 | } 65 | } 66 | 67 | try { 68 | const {text: docText, version: version, resolvedPath: resolvedUri} = 69 | this.documents.get(fileUri); 70 | const cached = this.compilerService.cache(fileUri, docText, version); 71 | if (service.isFailedParsedDocument(cached)) { 72 | return emptyOnHover; 73 | } 74 | 75 | // Get symbol we're hovering over. 76 | const nodeAtPos = getNodeAtPositionFromAst(cached.parse, cursorLoc); 77 | if (ast.isFindFailure(nodeAtPos)) { 78 | return emptyOnHover; 79 | } 80 | 81 | if (nodeAtPos.parent != null && ast.isFunctionParam(nodeAtPos.parent)) { 82 | // A function parameter is a free variable, so we can't resolve 83 | // it. Simply return. 84 | return onHoverPromise(nodeAtPos.parent); 85 | } 86 | 87 | if (!ast.isResolvable(nodeAtPos)) { 88 | return emptyOnHover; 89 | } 90 | 91 | const ctx = new ast.ResolutionContext( 92 | this.compilerService, this.documents, resolvedUri); 93 | const resolved = ast.tryResolveIndirections(nodeAtPos, ctx); 94 | 95 | // Handle the special cases. If we hover over a symbol that points 96 | // at a function of some sort (i.e., a `function` literal, a 97 | // `local` that has a bind that is a function, or an object field 98 | // that is a function), then we want to render the name and 99 | // parameters that function takes, rather than the definition of 100 | // the function itself. 101 | if (ast.isResolveFailure(resolved)) { 102 | if (ast.isUnresolved(resolved) || ast.isUnresolvedIndex(resolved)) { 103 | return emptyOnHover; 104 | } else if (ast.isResolvedFreeVar(resolved)) { 105 | return onHoverPromise(resolved.variable); 106 | } else if (ast.isResolvedFunction(resolved)) { 107 | return onHoverPromise(resolved.functionNode); 108 | } else { 109 | return onHoverPromise(resolved); 110 | } 111 | } else { 112 | return onHoverPromise(resolved.value); 113 | } 114 | } catch (err) { 115 | console.log(err); 116 | return emptyOnHover; 117 | } 118 | } 119 | 120 | public onComplete = ( 121 | fileUri: editor.FileUri, cursorLoc: lexical.Location 122 | ): Promise => { 123 | const doc = this.documents.get(fileUri); 124 | 125 | return Promise.resolve().then( 126 | (): editor.CompletionInfo[] => { 127 | // 128 | // Generate suggestions. This process follows three steps: 129 | // 130 | // 1. Try to parse the document text. 131 | // 2. If we succeed, go to cursor, select that node, and if 132 | // it's an identifier that can be completed, then return 133 | // the environment. 134 | // 3. If we fail, go try to go to the "hole" where the 135 | // identifier exists. 136 | // 137 | 138 | try { 139 | const compiled = this.compilerService.cache( 140 | fileUri, doc.text, doc.version); 141 | const lines = doc.text.split("\n"); 142 | 143 | // Lets us know whether the user has typed something like 144 | // `foo` or `foo.` (i.e., whether they are "dotting into" 145 | // `foo`). In the case of the latter, we will want to emit 146 | // suggestions from the members of `foo`. 147 | const lastCharIsDot = 148 | lines[cursorLoc.line-1][cursorLoc.column-2] === "."; 149 | 150 | let node: ast.Node | null = null; 151 | if (service.isParsedDocument(compiled)) { 152 | // Success case. The document parses, and we can offer 153 | // suggestions from a well-formed document. 154 | 155 | return this.completionsFromParse( 156 | fileUri, compiled, cursorLoc, lastCharIsDot); 157 | } else { 158 | const lastParse = this.compilerService.getLastSuccess(fileUri); 159 | if (lastParse == null) { 160 | return []; 161 | } 162 | 163 | return this.completionsFromFailedParse( 164 | fileUri, compiled, lastParse, cursorLoc, lastCharIsDot); 165 | } 166 | } catch (err) { 167 | console.log(err); 168 | return []; 169 | } 170 | }); 171 | } 172 | 173 | // -------------------------------------------------------------------------- 174 | // Completion methods. 175 | // -------------------------------------------------------------------------- 176 | 177 | // completionsFromParse takes a `ParsedDocument` (i.e., a 178 | // successfully-parsed document), a cursor location, and an 179 | // indication of whether the user is "dotting in" to a property, and 180 | // produces a list of autocomplete suggestions. 181 | public completionsFromParse = ( 182 | fileUri: editor.FileUri, compiled: service.ParsedDocument, 183 | cursorLoc: lexical.Location, 184 | lastCharIsDot: boolean, 185 | ): editor.CompletionInfo[] => { 186 | // IMPLEMENTATION NOTES: We have kept this method relatively free 187 | // of calls to `this` so that we don't have to mock out more of 188 | // the analyzer to test it. 189 | 190 | let foundNode = getNodeAtPositionFromAst( 191 | compiled.parse, cursorLoc); 192 | if (ast.isAnalyzableFindFailure(foundNode)) { 193 | if (foundNode.kind === "NotIdentifier") { 194 | return []; 195 | } 196 | if (foundNode.terminalNodeOnCursorLine != null) { 197 | foundNode = foundNode.terminalNodeOnCursorLine; 198 | } else { 199 | foundNode = foundNode.tightestEnclosingNode; 200 | } 201 | } else if (ast.isUnanalyzableFindFailure(foundNode)) { 202 | return []; 203 | } 204 | 205 | return this.completionsFromNode( 206 | fileUri, foundNode, cursorLoc, lastCharIsDot); 207 | } 208 | 209 | // completionsFromFailedParse takes a `FailedParsedDocument` (i.e., 210 | // a document that does not parse), a `ParsedDocument` (i.e., a 211 | // last-known good parse for the document), a cursor location, and 212 | // an indication of whether the user is "dotting in" to a property, 213 | // and produces a list of autocomplete suggestions. 214 | public completionsFromFailedParse = ( 215 | fileUri: editor.FileUri, compiled: service.FailedParsedDocument, 216 | lastParse: service.ParsedDocument, 217 | cursorLoc: lexical.Location, lastCharIsDot: boolean, 218 | ): editor.CompletionInfo[] => { 219 | // IMPLEMENTATION NOTES: We have kept this method relatively free 220 | // of calls to `this` so that we don't have to mock out more of 221 | // the analyzer to test it. 222 | // 223 | // Failure case. The document does not parse, so we need 224 | // to: 225 | // 226 | // 1. Obtain a partial parse from the parser. 227 | // 2. Get our "best guess" for where in the AST the user's 228 | // cursor would be, if the document did parse. 229 | // 3. Use the partial parse and the environment "best 230 | // guess" to create suggestions based on the context 231 | // of where the user is typing. 232 | 233 | if ( 234 | service.isLexFailure(compiled.parse) || 235 | compiled.parse.parseError.rest == null 236 | ) { 237 | return []; 238 | } 239 | 240 | // Step 1, get the "rest" of the parse, i.e., the partial 241 | // parse emitted by the parser. 242 | const rest = compiled.parse.parseError.rest; 243 | const restEnd = rest.loc.end; 244 | 245 | if (rest == null) { 246 | throw new Error(`INTERNAL ERROR: rest should never be null`); 247 | } else if ( 248 | !cursorLoc.inRange(rest.loc) && 249 | !(restEnd.line === cursorLoc.line && cursorLoc.column === restEnd.column + 1) 250 | ) { 251 | // NOTE: the `+ 1` correctly captures the case of the 252 | // user typing `.`. 253 | 254 | // Explicitly handle the case that the user has pressed a 255 | // newline and `.` character. For example, in the third line 256 | // below: 257 | // 258 | // metadata.withAnnotations({foo: "bar"}) 259 | // 260 | // .; 261 | // 262 | // Return no suggestions if the parse is not broken at the 263 | // cursor. 264 | const lines = compiled.text.split(/\r\n|\r|\n/g); 265 | const gapLines = lines.slice(restEnd.line, cursorLoc.line); 266 | if (gapLines.length == 0) { 267 | return []; 268 | } else if (gapLines.length === 1) { 269 | const gap = gapLines[0].slice(cursorLoc.column, restEnd.column); 270 | if (gap.trim().length != 0) { 271 | return []; 272 | } 273 | } else { 274 | const firstGap = gapLines[0].slice(restEnd.column); 275 | const lastGap = gapLines[gapLines.length - 1] 276 | .slice( 277 | 0, 278 | cursorLoc.column - (lastCharIsDot ? 2 : 1)); 279 | const middleGapLengths = gapLines 280 | .slice(1, gapLines.length - 2) 281 | .reduce((gapLenAcc: number, line: string) => gapLenAcc + line.trim().length, 0); 282 | 283 | if (firstGap.trim().length !== 0 || middleGapLengths !== 0 || lastGap.trim().length !== 0) { 284 | return []; 285 | } 286 | } 287 | 288 | cursorLoc = restEnd; 289 | } 290 | 291 | // Step 2, try to find the "best guess". 292 | let foundNode = getNodeAtPositionFromAst( 293 | lastParse.parse, cursorLoc); 294 | if (ast.isAnalyzableFindFailure(foundNode)) { 295 | if (foundNode.terminalNodeOnCursorLine != null) { 296 | foundNode = foundNode.terminalNodeOnCursorLine; 297 | } else { 298 | foundNode = foundNode.tightestEnclosingNode; 299 | } 300 | } else if (ast.isUnanalyzableFindFailure(foundNode)) { 301 | return []; 302 | } 303 | 304 | // Step 3, combine the partial parse and the environment 305 | // of the "best guess" to attempt to create meaningful 306 | // suggestions for the user. 307 | if (foundNode.env == null) { 308 | throw new Error("INTERNAL ERROR: Node environment can't be null"); 309 | } 310 | new ast 311 | .InitializingVisitor(rest, foundNode, foundNode.env) 312 | .visit(); 313 | 314 | // Create suggestions. 315 | return this.completionsFromNode(fileUri, rest, cursorLoc, lastCharIsDot); 316 | } 317 | 318 | // completionsFromNode takes a `Node`, a cursor location, and an 319 | // indication of whether the user is "dotting in" to a property, and 320 | // produces a list of autocomplete suggestions. 321 | private completionsFromNode = ( 322 | fileUri: editor.FileUri, node: ast.Node, cursorLoc: lexical.Location, 323 | lastCharIsDot: boolean, 324 | ): editor.CompletionInfo[] => { 325 | // Attempt to resolve the node. 326 | const ctx = new ast.ResolutionContext( 327 | this.compilerService, this.documents, fileUri); 328 | const resolved = ast.tryResolveIndirections(node, ctx); 329 | 330 | if (ast.isUnresolved(resolved)) { 331 | // If we could not even partially resolve a node (as we do, 332 | // e.g., when an index target resolves, but the ID doesn't), 333 | // then create suggestions from the environment. 334 | return node.env != null 335 | ? envToSuggestions(node.env) 336 | : []; 337 | } else if (ast.isUnresolvedIndexTarget(resolved)) { 338 | // One of the targets in some index expression failed to 339 | // resolve, so we have no suggestions. For example, in 340 | // `foo.bar.baz.bat`, if any of `foo`, `bar`, or `baz` fail, 341 | // then we have nothing to suggest as the user is typing `bat`. 342 | return []; 343 | } else if (ast.isUnresolvedIndexId(resolved)) { 344 | // We have successfully resolved index target, but not the index 345 | // ID, so generate suggestions from the resolved target. For 346 | // example, if the user types `foo.b`, then we would generate 347 | // suggestions from the members of `foo`. 348 | return this.completionsFromFields(resolved.resolvedTarget); 349 | } else if ( 350 | ast.isResolvedFunction(resolved) || 351 | ast.isResolvedFreeVar(resolved) || 352 | (!lastCharIsDot && ast.isIndexedObjectFields(resolved.value) || 353 | ast.isNode(resolved.value)) 354 | ) { 355 | // Our most complex case. One of two things is true: 356 | // 357 | // 1. Resolved the ID to a function or a free param, in which 358 | // case we do not want to emit any suggestions, or 359 | // 2. The user has NOT typed a dot, AND the resolve node is not 360 | // fields addressable, OR it's a node. In other words, the 361 | // user has typed something like `foo` (and specifically not 362 | // `foo.`, which is covered in another case), and `foo` 363 | // completely resolves, either to a value (e.g., a number 364 | // like 3) or a set of fields (i.e., `foo` is an object). In 365 | // both cases the user has type variable, and we don't want 366 | // to suggest anything; if they wanted to see the members of 367 | // `foo`, they should type `foo.`. 368 | return []; 369 | } else if (lastCharIsDot && ast.isIndexedObjectFields(resolved.value)) { 370 | // User has typed a dot, and the resolved symbol is 371 | // fields-resolvable, so we can return the fields of the 372 | // expression. For example, if the user types `foo.`, then we 373 | // can suggest the members of `foo`. 374 | return this.completionsFromFields(resolved.value); 375 | } 376 | 377 | // Catch-all case. Suggest nothing. 378 | return []; 379 | } 380 | 381 | private completionsFromFields = ( 382 | fieldSet: ast.IndexedObjectFields 383 | ): editor.CompletionInfo[] => { 384 | // Attempt to get all the possible fields we could suggest. If the 385 | // resolved item is an `ObjectNode`, just use its fields; if it's 386 | // a mixin of two objects, merge them and use the merged fields 387 | // instead. 388 | 389 | return im.List(fieldSet.values()) 390 | .filter((field: ast.ObjectField) => 391 | field != null && field.id != null && field.expr2 != null && field.kind !== "ObjectLocal") 392 | .map((field: ast.ObjectField) => { 393 | if (field == null || field.id == null || field.expr2 == null) { 394 | throw new Error( 395 | `INTERNAL ERROR: Filtered out null fields, but found field null`); 396 | } 397 | 398 | let kind: editor.CompletionType = "Field"; 399 | if (field.methodSugar) { 400 | kind = "Method"; 401 | } 402 | 403 | const comments = this.getComments(field); 404 | return { 405 | label: field.id.name, 406 | kind: kind, 407 | documentation: comments || undefined, 408 | }; 409 | }) 410 | .toArray(); 411 | } 412 | 413 | // -------------------------------------------------------------------------- 414 | // Completion methods. 415 | // -------------------------------------------------------------------------- 416 | 417 | private renderOnhoverMessage = ( 418 | fileUri: editor.FileUri, node: ast.Node | ast.IndexedObjectFields, 419 | ): editor.LanguageString[] => { 420 | if (ast.isIndexedObjectFields(node)) { 421 | if (node.count() === 0) { 422 | return []; 423 | } 424 | 425 | const first = node.first(); 426 | if (first.parent == null) { 427 | return []; 428 | } 429 | node = first.parent; 430 | } 431 | 432 | const commentText: string | null = this.resolveComments(node); 433 | 434 | const doc = this.documents.get(fileUri); 435 | let line: string = doc.text.split(os.EOL) 436 | .slice(node.loc.begin.line - 1, node.loc.end.line) 437 | .join("\n"); 438 | 439 | if (ast.isFunctionParam(node)) { 440 | // A function parameter is either a free variable, or a free 441 | // variable with a default value. Either way, there's not more 442 | // we can know statically, so emit that. 443 | line = node.prettyPrint(); 444 | } 445 | 446 | line = node.prettyPrint(); 447 | 448 | return [ 449 | {language: 'jsonnet', value: line}, 450 | commentText, 451 | ]; 452 | } 453 | 454 | // -------------------------------------------------------------------------- 455 | // Comment resolution. 456 | // -------------------------------------------------------------------------- 457 | 458 | // resolveComments takes a node as argument, and attempts to find the 459 | // comments that correspond to that node. For example, if the node 460 | // passed in exists inside an object field, we will explore the parent 461 | // nodes until we find the object field, and return the comments 462 | // associated with that (if any). 463 | public resolveComments = (node: ast.Node | null): string | null => { 464 | while (true) { 465 | if (node == null) { return null; } 466 | 467 | switch (node.type) { 468 | case "ObjectFieldNode": { 469 | // Only retrieve comments for. 470 | const field = node; 471 | if (field.kind != "ObjectFieldID" && field.kind == "ObjectFieldStr") { 472 | return null; 473 | } 474 | 475 | // Convert to field object, pull comments out. 476 | return this.getComments(field); 477 | } 478 | default: { 479 | node = node.parent; 480 | continue; 481 | } 482 | } 483 | } 484 | } 485 | 486 | private getComments = (field: ast.ObjectField): string | null => { 487 | // Convert to field object, pull comments out. 488 | const comments = field.headingComments; 489 | if (comments == null) { 490 | return null; 491 | } 492 | 493 | return comments.text.join(os.EOL); 494 | } 495 | } 496 | 497 | // 498 | // Utilities. 499 | // 500 | 501 | export const getNodeAtPositionFromAst = ( 502 | rootNode: ast.Node, pos: lexical.Location 503 | ): ast.Node | ast.FindFailure => { 504 | // Special case. Make sure that if the cursor is beyond the range 505 | // of text of the last good parse, we just return the last node. 506 | // For example, if the user types a `.` character at the end of 507 | // the document, the document now fails to parse, and the cursor 508 | // is beyond the range of text of the last good parse. 509 | const endLoc = rootNode.loc.end; 510 | if (endLoc.line < pos.line || (endLoc.line == pos.line && endLoc.column < pos.column)) { 511 | pos = endLoc; 512 | } 513 | 514 | const visitor = new ast.CursorVisitor(pos, rootNode); 515 | visitor.visit(); 516 | const tightestNode = visitor.nodeAtPosition; 517 | return tightestNode; 518 | } 519 | 520 | const envToSuggestions = (env: ast.Environment): editor.CompletionInfo[] => { 521 | return env.map((value: ast.LocalBind | ast.FunctionParam, key: string) => { 522 | // TODO: Fill in documentation later. This might involve trying 523 | // to parse function comment to get comments about different 524 | // parameters. 525 | return { 526 | label: key, 527 | kind: "Variable", 528 | }; 529 | }) 530 | .toArray(); 531 | } 532 | -------------------------------------------------------------------------------- /compiler/static-analysis/service.ts: -------------------------------------------------------------------------------- 1 | import * as im from 'immutable'; 2 | 3 | import * as ast from '../lexical-analysis/ast'; 4 | import * as lexer from '../lexical-analysis/lexer'; 5 | import * as lexical from '../lexical-analysis/lexical'; 6 | import * as parser from '../lexical-analysis/parser'; 7 | 8 | // ParsedDocument represents a successfully-parsed document. 9 | export class ParsedDocument { 10 | constructor( 11 | readonly text: string, 12 | readonly lex: lexer.Tokens, 13 | readonly parse: ast.Node, 14 | readonly version?: number, 15 | ) {} 16 | } 17 | 18 | export const isParsedDocument = (testMe: any): testMe is ParsedDocument => { 19 | return testMe instanceof ParsedDocument; 20 | } 21 | 22 | // FailedParsedDocument represents a document that failed to parse. 23 | export class FailedParsedDocument { 24 | constructor( 25 | readonly text: string, 26 | readonly parse: LexFailure | ParseFailure, 27 | readonly version?: number, 28 | ) {} 29 | } 30 | 31 | export const isFailedParsedDocument = ( 32 | testMe: any 33 | ): testMe is FailedParsedDocument => { 34 | return testMe instanceof FailedParsedDocument; 35 | } 36 | 37 | // LexFailure represents a failure to lex a document. 38 | export class LexFailure { 39 | constructor( 40 | readonly lex: lexer.Tokens, 41 | readonly lexError: lexical.StaticError, 42 | ) {} 43 | } 44 | 45 | export const isLexFailure = (testMe: any): testMe is LexFailure => { 46 | return testMe instanceof LexFailure; 47 | } 48 | 49 | // ParseFailure represents a failure to parse a document. 50 | export class ParseFailure { 51 | constructor( 52 | readonly lex: lexer.Tokens, 53 | // TODO: Enable this. 54 | // readonly parse: ast.Node, 55 | readonly parseError: lexical.StaticError, 56 | ) {} 57 | } 58 | 59 | export const isParseFailure = (testMe: any): testMe is ParseFailure => { 60 | return testMe instanceof ParseFailure; 61 | } 62 | 63 | // CompilerService represents the core service for parsing and caching 64 | // parses of documents. 65 | export interface LexicalAnalyzerService { 66 | cache: ( 67 | fileUri: string, text: string, version?: number 68 | ) => ParsedDocument | FailedParsedDocument 69 | getLastSuccess: (fileUri: string) => ParsedDocument | null 70 | delete: (fileUri: string) => void 71 | } 72 | -------------------------------------------------------------------------------- /compiler/static.ts: -------------------------------------------------------------------------------- 1 | export * from "./static-analysis/analyzer"; 2 | export * from "./static-analysis/service"; 3 | -------------------------------------------------------------------------------- /images/kube-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heptio/vscode-jsonnet/0957d4235be011f2f2ac2f8af51cd7927852eeaa/images/kube-demo.gif -------------------------------------------------------------------------------- /language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "//", 4 | "blockComment": [ "/*", "*/" ] 5 | }, 6 | // Symbols used as brackets. 7 | "brackets": [ 8 | ["{", "}"], 9 | ["[", "]"], 10 | ["(", ")"] 11 | ], 12 | // Symbols that are auto closed when typing. 13 | "autoClosingPairs": [ 14 | { "open": "{", "close": "}," }, 15 | { "open": "[", "close": "]," }, 16 | { "open": "(", "close": ")" }, 17 | { "open": "'", "close": "'", "notIn": ["string", "comment"] }, 18 | { "open": "\"", "close": "\"", "notIn": ["string"] }, 19 | { "open": "/**", "close": " */", "notIn": ["string"] } 20 | ], 21 | // Symbols that that can be used to surround a selection. 22 | "surroundingPairs": [ 23 | ["{", "}"], 24 | ["[", "]"], 25 | ["(", ")"], 26 | ["\"", "\""], 27 | ["'", "'"] 28 | ] 29 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "activationEvents": [ 3 | "onLanguage:jsonnet", 4 | "onCommand:jsonnet.previewToSide", 5 | "onCommand:jsonnet.preview" 6 | ], 7 | "categories": [ 8 | "Languages" 9 | ], 10 | "contributes": { 11 | "commands": [ 12 | { 13 | "command": "jsonnet.previewToSide", 14 | "title": "Jsonnet: Open Preview to the Side" 15 | }, 16 | { 17 | "command": "jsonnet.preview", 18 | "title": "Jsonnet: Open Preview" 19 | } 20 | ], 21 | "configuration": { 22 | "properties": { 23 | "jsonnet.executablePath": { 24 | "default": null, 25 | "description": "Location of the `jsonnet` executable.", 26 | "type": "string" 27 | }, 28 | "jsonnet.extStrs": { 29 | "default": null, 30 | "description": "External strings to pass to `jsonnet` executable.", 31 | "type": "object" 32 | }, 33 | "jsonnet.libPaths": { 34 | "default": [], 35 | "description": "Additional paths to search for libraries when compiling Jsonnet code.", 36 | "type": "array" 37 | }, 38 | "jsonnet.outputFormat": { 39 | "default": "yaml", 40 | "description": "Preview output format (yaml / json)", 41 | "enum": [ 42 | "json", 43 | "yaml" 44 | ] 45 | } 46 | }, 47 | "title": "Jsonnet configuration", 48 | "type": "object" 49 | }, 50 | "grammars": [ 51 | { 52 | "language": "jsonnet", 53 | "path": "./syntaxes/jsonnet.tmLanguage.json", 54 | "scopeName": "source.jsonnet" 55 | } 56 | ], 57 | "keybindings": [ 58 | { 59 | "command": "jsonnet.previewToSide", 60 | "key": "shift+ctrl+i", 61 | "mac": "shift+cmd+i", 62 | "when": "editorFocus" 63 | } 64 | ], 65 | "languages": [ 66 | { 67 | "aliases": [ 68 | "Jsonnet", 69 | "jsonnet" 70 | ], 71 | "configuration": "./language-configuration.json", 72 | "extensions": [ 73 | ".jsonnet", 74 | ".libsonnet" 75 | ], 76 | "id": "jsonnet" 77 | } 78 | ] 79 | }, 80 | "dependencies": { 81 | "filepath": "^1.1.0", 82 | "immutable": "^3.8.1", 83 | "js-yaml": "^3.0.0", 84 | "tmp": "0.0.33", 85 | "vscode-languageclient": "^3.1.0", 86 | "vscode-languageserver": "^3.1.0" 87 | }, 88 | "description": "Language support for Jsonnet", 89 | "devDependencies": { 90 | "@types/chai": "^3.5.0", 91 | "@types/mocha": "^2.2.42", 92 | "@types/node": "^6.0.40", 93 | "browserify": "^14.3.0", 94 | "chai": "^3.5.0", 95 | "mocha": "^5.0.1", 96 | "typescript": "^2.3.2", 97 | "vscode": "^1.0.0" 98 | }, 99 | "displayName": "Jsonnet", 100 | "engines": { 101 | "vscode": "^1.18.x" 102 | }, 103 | "homepage": "https://github.com/heptio/vscode-jsonnet/blob/master/README.md", 104 | "license": "SEE LICENSE IN 'LICENSE' file", 105 | "main": "./out/client/extension", 106 | "name": "jsonnet", 107 | "publisher": "heptio", 108 | "repository": { 109 | "type": "git", 110 | "url": "https://github.com/heptio/vscode-jsonnet.git" 111 | }, 112 | "scripts": { 113 | "compile": "tsc -watch -p ./", 114 | "compile-once": "tsc -p ./", 115 | "compile-site": "browserify ./out/site/main.js > ksonnet.js", 116 | "postinstall": "node ./node_modules/vscode/bin/install", 117 | "test": "node ./node_modules/vscode/bin/test", 118 | "vscode:prepublish": "tsc -p ./" 119 | }, 120 | "version": "0.1.0" 121 | } 122 | -------------------------------------------------------------------------------- /package.jsonnet: -------------------------------------------------------------------------------- 1 | local package = import "./package.libsonnet"; 2 | 3 | local contributes = package.contributes; 4 | local event = package.event; 5 | local grammar = package.contributes.grammar; 6 | local keybinding = package.contributes.keybinding; 7 | local language = package.contributes.language; 8 | local languageSpec = package.languageSpec; 9 | 10 | local jsonnetLanguage = languageSpec.Default( 11 | "jsonnet", "Jsonnet", [".jsonnet",".libsonnet"]); 12 | 13 | local preview = contributes.command.Default( 14 | "jsonnet.preview", 15 | "Jsonnet: Open Preview"); 16 | 17 | local previewToSide = contributes.command.Default( 18 | "jsonnet.previewToSide", 19 | "Jsonnet: Open Preview to the Side"); 20 | 21 | local previewKeybinding = keybinding.FromCommand( 22 | previewToSide, "editorFocus", "shift+ctrl+i", mac="shift+cmd+i"); 23 | 24 | package.Default() + 25 | package.Name(jsonnetLanguage.name) + 26 | package.DisplayName(jsonnetLanguage.displayName) + 27 | package.Description("Language support for Jsonnet") + 28 | package.Version("0.0.15") + 29 | package.Publisher("heptio") + 30 | package.License("SEE LICENSE IN 'LICENSE' file") + 31 | package.Homepage("https://github.com/heptio/vscode-jsonnet/blob/master/README.md") + 32 | package.Category("Languages") + 33 | package.ActivationEvent(event.OnLanguage(jsonnetLanguage.name)) + 34 | package.ActivationEvent(event.OnCommand(previewToSide.command)) + 35 | package.ActivationEvent(event.OnCommand(preview.command)) + 36 | package.Main("./out/client/extension") + 37 | 38 | // Repository. 39 | package.repository.Default( 40 | "git", "https://github.com/heptio/vscode-jsonnet.git") + 41 | 42 | // Engines. 43 | package.engines.VsCode("^1.10.0") + 44 | 45 | // Contribution points. 46 | package.contributes.Language(language.FromLanguageSpec( 47 | jsonnetLanguage, "./language-configuration.json")) + 48 | package.contributes.Grammar(grammar.FromLanguageSpec( 49 | jsonnetLanguage, "source.jsonnet", "./syntaxes/jsonnet.tmLanguage.json")) + 50 | package.contributes.Command(previewToSide) + 51 | package.contributes.Command(preview) + 52 | package.contributes.Keybinding(previewKeybinding) + 53 | package.contributes.DefaultConfiguration( 54 | "Jsonnet configuration", 55 | contributes.configuration.DefaultStringProperty( 56 | "jsonnet.executablePath", "Location of the `jsonnet` executable.") + 57 | contributes.configuration.DefaultArrayProperty( 58 | "jsonnet.libPaths", 59 | "Additional paths to search for libraries when compiling Jsonnet code.") + 60 | contributes.configuration.DefaultObjectProperty( 61 | "jsonnet.extStrs", "External strings to pass to `jsonnet` executable.") + 62 | contributes.configuration.DefaultEnumProperty( 63 | "jsonnet.outputFormat", 64 | "Preview output format (yaml / json)", 65 | ["json", "yaml"], 66 | "yaml")) + 67 | // Everything else. 68 | { 69 | scripts: { 70 | "vscode:prepublish": "tsc -p ./", 71 | compile: "tsc -watch -p ./", 72 | "compile-once": "tsc -p ./", 73 | "compile-site": "browserify ./out/site/main.js > ksonnet.js", 74 | postinstall: "node ./node_modules/vscode/bin/install", 75 | test: "node ./node_modules/vscode/bin/test" 76 | }, 77 | dependencies: { 78 | "js-yaml": "^3.0.0", 79 | "immutable": "^3.8.1", 80 | "vscode-languageclient": "^3.1.0", 81 | "vscode-languageserver": "^3.1.0", 82 | }, 83 | devDependencies: { 84 | browserify: "^14.3.0", 85 | typescript: "^2.3.2", 86 | vscode: "^1.0.0", 87 | mocha: "^2.3.3", 88 | chai: "^3.5.0", 89 | "@types/chai": "^3.5.0", 90 | "@types/node": "^6.0.40", 91 | "@types/mocha": "^2.2.32", 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /package.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | 3 | Default():: { 4 | engines: {}, 5 | categories: [], 6 | activationEvents: [], 7 | contributes: $.contributes.Default(), 8 | }, 9 | 10 | Name(name):: {name: name}, 11 | DisplayName(displayName):: {displayName: displayName}, 12 | Description(description):: {description: description}, 13 | Version(version):: {version: version}, 14 | Publisher(publisher):: {publisher: publisher}, 15 | License(license):: {license: license}, 16 | Homepage(homepage):: {homepage: homepage}, 17 | Category(category):: {categories+: [category]}, 18 | ActivationEvent(event):: {activationEvents+: [event]}, 19 | Main(main):: {main: main}, 20 | 21 | repository:: { 22 | Default(type, url):: { 23 | repository: { 24 | type: type, 25 | url: url, 26 | }, 27 | }, 28 | }, 29 | 30 | engines:: { 31 | VsCode(vscodeVersion):: { 32 | engines+: { 33 | vscode: vscodeVersion, 34 | }, 35 | }, 36 | }, 37 | 38 | event:: { 39 | OnLanguage(languageId):: "onLanguage:%s" % languageId, 40 | OnCommand(id):: "onCommand:%s" % id, 41 | }, 42 | 43 | languageSpec:: { 44 | Default(name, displayName, extensions):: { 45 | name: name, 46 | displayName: displayName, 47 | extensions: extensions, 48 | } 49 | }, 50 | 51 | contributes:: { 52 | Default():: { 53 | languages: [], 54 | grammars: [], 55 | commands: [], 56 | keybindings: [], 57 | }, 58 | 59 | Language(language):: {contributes+: {languages+: [language]}}, 60 | Grammar(grammar):: {contributes+: {grammars+: [grammar]}}, 61 | Command(command):: {contributes+: {commands+: [command]}}, 62 | Keybinding(keybinding):: {contributes+: {keybindings+: [keybinding]}}, 63 | 64 | DefaultConfiguration(title, properties):: { 65 | contributes+: { 66 | configuration: { 67 | type: "object", 68 | title: title, 69 | properties: properties, 70 | }, 71 | }, 72 | }, 73 | 74 | configuration:: { 75 | DefaultStringProperty(property, description, default=null):: { 76 | [property]: { 77 | type: "string", 78 | default: default, 79 | description: description, 80 | }, 81 | }, 82 | 83 | DefaultObjectProperty(property, description, default=null):: { 84 | [property]: { 85 | type: "object", 86 | default: default, 87 | description: description, 88 | }, 89 | }, 90 | 91 | DefaultArrayProperty(property, description, default=[]):: { 92 | [property]: { 93 | type: "array", 94 | default: default, 95 | description: description, 96 | }, 97 | }, 98 | 99 | DefaultEnumProperty(property, description, enum=[], default=null):: { 100 | [property]: { 101 | default: default, 102 | enum: enum, 103 | description: description, 104 | }, 105 | }, 106 | 107 | }, 108 | 109 | command:: { 110 | Default(command, title):: { 111 | command: command, 112 | title: title, 113 | }, 114 | }, 115 | 116 | keybinding:: { 117 | FromCommand(command, when, key, mac=null):: { 118 | command: command.command, 119 | key: key, 120 | [if !(mac == null) then "mac"]: mac, 121 | when: when, 122 | }, 123 | }, 124 | 125 | language:: { 126 | FromLanguageSpec(language, configurationFile):: { 127 | id: language.name, 128 | aliases: [language.displayName, language.name], 129 | extensions: language.extensions, 130 | configuration: configurationFile, 131 | }, 132 | }, 133 | 134 | grammar:: { 135 | FromLanguageSpec(language, scopeName, path):: { 136 | language: language.name, 137 | scopeName: scopeName, 138 | path: path, 139 | }, 140 | }, 141 | }, 142 | } 143 | -------------------------------------------------------------------------------- /server/diagnostic.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as server from 'vscode-languageserver'; 4 | 5 | import * as im from 'immutable'; 6 | 7 | import * as ast from '../compiler/lexical-analysis/ast'; 8 | import * as editor from '../compiler/editor'; 9 | import * as lexical from '../compiler/lexical-analysis/lexical'; 10 | import * as _static from "../compiler/static"; 11 | 12 | // fromFailure creates a diagnostic from a `LexFailure | 13 | // ParseFailure`. 14 | export const fromFailure = ( 15 | error: _static.LexFailure | _static.ParseFailure 16 | ): server.Diagnostic => { 17 | let begin: lexical.Location | null = null; 18 | let end: lexical.Location | null = null; 19 | let message: string | null = null; 20 | if (_static.isLexFailure(error)) { 21 | begin = error.lexError.loc.begin; 22 | end = error.lexError.loc.end; 23 | message = error.lexError.msg; 24 | } else { 25 | begin = error.parseError.loc.begin; 26 | end = error.parseError.loc.end; 27 | message = error.parseError.msg; 28 | } 29 | 30 | return { 31 | severity: server.DiagnosticSeverity.Error, 32 | range: { 33 | start: {line: begin.line - 1, character: begin.column - 1}, 34 | end: {line: end.line - 1, character: end.column - 1}, 35 | }, 36 | message: `${message}`, 37 | source: `Jsonnet`, 38 | }; 39 | } 40 | 41 | // fromAst takes a Jsonnet AST and returns an array of `Diagnostic` 42 | // issues it finds. 43 | export const fromAst = ( 44 | root: ast.Node, libResolver: editor.LibPathResolver, 45 | ): server.Diagnostic[] => { 46 | const diags = new Visitor(root, libResolver); 47 | diags.visit(); 48 | return diags.diagnostics; 49 | } 50 | 51 | // ---------------------------------------------------------------------------- 52 | // Private utilities. 53 | // ---------------------------------------------------------------------------- 54 | 55 | // Visitor traverses the Jsonnet AST and accumulates `Diagnostic` 56 | // errors for reporting. 57 | class Visitor extends ast.VisitorBase { 58 | private diags = im.List(); 59 | 60 | constructor( 61 | root: ast.Node, 62 | private readonly libResolver: editor.LibPathResolver, 63 | ) { 64 | super(root); 65 | } 66 | 67 | get diagnostics(): server.Diagnostic[] { 68 | return this.diags.toArray(); 69 | } 70 | 71 | protected visitImport = (node: ast.Import): void => 72 | this.importDiagnostics(node); 73 | 74 | protected visitImportStr = (node: ast.ImportStr): void => 75 | this.importDiagnostics(node); 76 | 77 | private importDiagnostics = (node: ast.Import | ast.ImportStr): void => { 78 | if (!this.libResolver.resolvePath(node)) { 79 | const begin = node.loc.begin; 80 | const end = node.loc.end; 81 | const diagnostic = { 82 | severity: server.DiagnosticSeverity.Warning, 83 | range: { 84 | start: {line: begin.line - 1, character: begin.column - 1}, 85 | end: {line: end.line - 1, character: end.column - 1}, 86 | }, 87 | message: 88 | `Can't find path '${node.file}'. If the file is not in the ` + 89 | `current directory, it may be necessary to add it to the ` + 90 | `'jsonnet.libPaths'. If you are in vscode, you can press ` + 91 | `'cmd/ctrl-,' and add the path this library is located at to the ` + 92 | `'jsonnet.libPaths' array`, 93 | source: `Jsonnet`, 94 | }; 95 | 96 | this.diags = this.diags.push(diagnostic); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /server/local.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as proc from 'child_process'; 4 | import * as url from 'url'; 5 | 6 | import * as im from 'immutable'; 7 | import * as server from 'vscode-languageserver'; 8 | 9 | import * as ast from '../compiler/lexical-analysis/ast'; 10 | import * as editor from '../compiler/editor'; 11 | import * as lexer from '../compiler/lexical-analysis/lexer'; 12 | import * as lexical from '../compiler/lexical-analysis/lexical'; 13 | import * as parser from '../compiler/lexical-analysis/parser'; 14 | import * as _static from "../compiler/static"; 15 | 16 | export class VsDocumentManager implements editor.DocumentManager { 17 | constructor( 18 | private readonly documents: server.TextDocuments, 19 | private readonly libResolver: editor.LibPathResolver, 20 | ) { } 21 | 22 | get = ( 23 | fileSpec: editor.FileUri | ast.Import | ast.ImportStr, 24 | ): {text: string, version?: number, resolvedPath: string} => { 25 | const parsedFileUri = this.libResolver.resolvePath(fileSpec); 26 | if (parsedFileUri == null) { 27 | throw new Error(`Could not open file`); 28 | } 29 | 30 | const fileUri = parsedFileUri.href; 31 | const filePath = parsedFileUri.path; 32 | if (fileUri == null || filePath == null) { 33 | throw new Error(`INTERNAL ERROR: ill-formed, null href or path`); 34 | } 35 | 36 | const version = fs.statSync(filePath).mtime.valueOf(); 37 | const doc = this.documents.get(fileUri); 38 | if (doc == null) { 39 | const doc = this.fsCache.get(fileUri); 40 | if (doc != null && version == doc.version) { 41 | // Return cached version if modified time is the same. 42 | return { 43 | text: doc.text, 44 | version: doc.version, 45 | resolvedPath: fileUri, 46 | }; 47 | } 48 | 49 | // Else, cache it. 50 | const text = fs.readFileSync(filePath).toString(); 51 | const cached = { 52 | text: text, 53 | version: version, 54 | resolvedPath: fileUri 55 | }; 56 | this.fsCache = this.fsCache.set(fileUri, cached); 57 | return cached; 58 | } else { 59 | // Delete from `fsCache` just in case we were `import`'ing a 60 | // file and have since opened it. 61 | this.fsCache = this.fsCache.delete(fileUri); 62 | return { 63 | text: doc.getText(), 64 | version: doc.version, 65 | resolvedPath: fileUri, 66 | } 67 | } 68 | } 69 | 70 | private fsCache = im.Map(); 71 | } 72 | 73 | export class VsCompilerService implements _static.LexicalAnalyzerService { 74 | // 75 | // CompilerService implementation. 76 | // 77 | 78 | public cache = ( 79 | fileUri: string, text: string, version?: number 80 | ): _static.ParsedDocument | _static.FailedParsedDocument => { 81 | // 82 | // There are 3 possible outcomes: 83 | // 84 | // 1. We successfully parse the document. Cache. 85 | // 2. We successfully lex but fail to parse. Return 86 | // `PartialParsedDocument`. 87 | // 3. We fail to lex. Return `PartialParsedDocument`. 88 | // 89 | 90 | // Attempt to retrieve cached parse if document versions are the 91 | // same. If version is undefined, it comes from a source that 92 | // doesn't track document version, and we always re-parse. 93 | const tryGet = this.docCache.get(fileUri); 94 | if (tryGet !== undefined && tryGet.version !== undefined && 95 | tryGet.version === version 96 | ) { 97 | return tryGet; 98 | } 99 | 100 | // TODO: Replace this with a URL provider abstraction. 101 | const parsedUrl = url.parse(fileUri); 102 | if (!parsedUrl || !parsedUrl.path) { 103 | throw new Error(`INTERNAL ERROR: Failed to parse URI '${fileUri}'`); 104 | } 105 | 106 | const lex = lexer.Lex(parsedUrl.path, text); 107 | if (lexical.isStaticError(lex)) { 108 | // TODO: emptyTokens is not right. Fill it in. 109 | const fail = new _static.LexFailure(lexer.emptyTokens, lex); 110 | return new _static.FailedParsedDocument(text, fail, version); 111 | } 112 | 113 | const parse = parser.Parse(lex); 114 | if (lexical.isStaticError(parse)) { 115 | const fail = new _static.ParseFailure(lex, parse); 116 | return new _static.FailedParsedDocument(text, fail, version); 117 | } 118 | 119 | const parsedDoc = new _static.ParsedDocument(text, lex, parse, version); 120 | this.docCache = this.docCache.set(fileUri, parsedDoc); 121 | return parsedDoc; 122 | } 123 | 124 | public getLastSuccess = ( 125 | fileUri: string 126 | ): _static.ParsedDocument | null => { 127 | return this.docCache.has(fileUri) && this.docCache.get(fileUri) || null; 128 | } 129 | 130 | public delete = (fileUri: string): void => { 131 | this.docCache = this.docCache.delete(fileUri); 132 | } 133 | 134 | // 135 | // Private members. 136 | // 137 | 138 | private docCache = im.Map(); 139 | } 140 | 141 | export class VsPathResolver extends editor.LibPathResolver { 142 | protected pathExists = (path: string): boolean => { 143 | try { 144 | return fs.existsSync(path); 145 | } catch (err) { 146 | return false; 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import * as server from 'vscode-languageserver'; 5 | import * as url from 'url'; 6 | 7 | import * as im from 'immutable'; 8 | 9 | import * as ast from '../compiler/lexical-analysis/ast'; 10 | import * as diagnostic from './diagnostic'; 11 | import * as editor from '../compiler/editor'; 12 | import * as lexer from '../compiler/lexical-analysis/lexer'; 13 | import * as lexical from '../compiler/lexical-analysis/lexical'; 14 | import * as local from './local'; 15 | import * as _static from '../compiler/static'; 16 | 17 | // Create a connection for the server. The connection uses Node's IPC 18 | // as a transport 19 | const connection: server.IConnection = server.createConnection( 20 | new server.IPCMessageReader(process), 21 | new server.IPCMessageWriter(process)); 22 | 23 | // Create a simple text document manager. The text document manager 24 | // supports full document sync only 25 | const docs = new server.TextDocuments(); 26 | 27 | const compiler = new local.VsCompilerService(); 28 | 29 | const libResolver = new local.VsPathResolver(); 30 | const documentManager = new local.VsDocumentManager(docs, libResolver); 31 | 32 | const analyzer: _static.EventedAnalyzer = new _static.Analyzer( 33 | documentManager, compiler); 34 | 35 | const reportDiagnostics = (doc: server.TextDocument) => { 36 | const text = doc.getText(); 37 | const results = compiler.cache(doc.uri, text, doc.version); 38 | 39 | if (_static.isParsedDocument(results)) { 40 | connection.sendDiagnostics({ 41 | uri: doc.uri, 42 | diagnostics: diagnostic.fromAst(results.parse, libResolver), 43 | }); 44 | } else { 45 | connection.sendDiagnostics({ 46 | uri: doc.uri, 47 | diagnostics: [diagnostic.fromFailure(results.parse)], 48 | }); 49 | } 50 | }; 51 | 52 | // 53 | // TODO: We should find a way to move these hooks to a "init doc 54 | // manager" method, or something. 55 | // 56 | // TODO: We should abstract over these hooks with 57 | // `workspace.DocumentManager`. 58 | // 59 | 60 | docs.onDidOpen(openEvent => { 61 | const doc = openEvent.document; 62 | if (doc.languageId === "jsonnet") { 63 | reportDiagnostics(doc); 64 | return analyzer.onDocumentOpen(doc.uri, doc.getText(), doc.version); 65 | } 66 | }); 67 | docs.onDidSave(saveEvent => { 68 | // TODO: relay once we can get the "last good" parse, we can check 69 | // the env of the position, and then evaluate that identifier, or 70 | // splice it into the tree. We can perhaps split changes into those 71 | // that are single-line, and those that are multi-line. 72 | const doc = saveEvent.document; 73 | if (doc.languageId === "jsonnet") { 74 | reportDiagnostics(doc); 75 | return analyzer.onDocumentOpen(doc.uri, doc.getText(), doc.version); 76 | } 77 | }); 78 | docs.onDidClose(closeEvent => { 79 | // TODO: This is a bit simplistic. We'll need to have a graph of 80 | // files, eventually, so that we can reload any previews whose 81 | // dependencies we save. 82 | if (closeEvent.document.languageId === "jsonnet") { 83 | return analyzer.onDocumentClose(closeEvent.document.uri); 84 | } 85 | }); 86 | docs.onDidChangeContent(changeEvent => { 87 | if (changeEvent.document.languageId === "jsonnet") { 88 | reportDiagnostics(changeEvent.document); 89 | } 90 | }); 91 | 92 | // Make the text document manager listen on the connection 93 | // for open, change and close text document events 94 | docs.listen(connection); 95 | 96 | connection.onInitialize((params) => initializer(docs, params)); 97 | connection.onDidChangeConfiguration(params => configUpdateProvider(params)); 98 | connection.onHover(position => { 99 | const fileUri = position.textDocument.uri; 100 | return analyzer.onHover(fileUri, positionToLocation(position)); 101 | }); 102 | 103 | connection.onCompletion(position => { 104 | return analyzer 105 | .onComplete(position.textDocument.uri, positionToLocation(position)) 106 | .then( 107 | completions => completions.map(completionInfoToCompletionItem)); 108 | }); 109 | // Prevent the language server from complaining that 110 | // `onCompletionResolve` handle is not implemented. 111 | connection.onCompletionResolve(item => item); 112 | 113 | // Listen on the connection 114 | connection.listen(); 115 | 116 | 117 | export const initializer = ( 118 | documents: server.TextDocuments, 119 | params: server.InitializeParams, 120 | ): server.InitializeResult => { 121 | return { 122 | capabilities: { 123 | // Tell the client that the server works in FULL text 124 | // document sync mode 125 | textDocumentSync: documents.syncKind, 126 | // Tell the client that the server support code complete 127 | completionProvider: { 128 | resolveProvider: true, 129 | triggerCharacters: ["."], 130 | }, 131 | hoverProvider: true, 132 | } 133 | } 134 | } 135 | 136 | export const configUpdateProvider = ( 137 | change: server.DidChangeConfigurationParams, 138 | ): void => { 139 | if ( 140 | change.settings == null || change.settings.jsonnet == null || 141 | change.settings.jsonnet.libPaths == null 142 | ) { 143 | return; 144 | } 145 | 146 | const jsonnet = change.settings.jsonnet; 147 | 148 | if (jsonnet.executablePath != null) { 149 | jsonnet.libPaths.unshift(jsonnet.executablePath); 150 | } 151 | 152 | libResolver.libPaths = im.List(jsonnet.libPaths); 153 | } 154 | 155 | const positionToLocation = ( 156 | posParams: server.TextDocumentPositionParams 157 | ): lexical.Location => { 158 | return new lexical.Location( 159 | posParams.position.line + 1, 160 | posParams.position.character + 1); 161 | } 162 | 163 | const completionInfoToCompletionItem = ( 164 | completionInfo: editor.CompletionInfo 165 | ): server.CompletionItem => { 166 | let kindMapping: server.CompletionItemKind; 167 | switch (completionInfo.kind) { 168 | case "Field": { 169 | kindMapping = server.CompletionItemKind.Field; 170 | break; 171 | } 172 | case "Variable": { 173 | kindMapping = server.CompletionItemKind.Variable; 174 | break; 175 | } 176 | case "Method": { 177 | kindMapping = server.CompletionItemKind.Method; 178 | break; 179 | } 180 | default: throw new Error( 181 | `Unrecognized completion type '${completionInfo.kind}'`); 182 | } 183 | 184 | // Black magic type coercion. This allows us to avoid doing a 185 | // deep copy over to a new `CompletionItem` object, and 186 | // instead only re-assign the `kindMapping`. 187 | const completionItem = ((completionInfo)); 188 | completionItem.kind = kindMapping; 189 | return completionItem; 190 | } 191 | -------------------------------------------------------------------------------- /site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /site/main.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as url from 'url'; 3 | 4 | import * as im from 'immutable'; 5 | 6 | import * as ast from '../compiler/lexical-analysis/ast'; 7 | import * as editor from '../compiler/editor'; 8 | import * as lexer from '../compiler/lexical-analysis/lexer'; 9 | import * as lexical from '../compiler/lexical-analysis/lexical'; 10 | import * as parser from '../compiler/lexical-analysis/parser'; 11 | import * as _static from '../compiler/static'; 12 | 13 | declare var global: any; 14 | 15 | // ---------------------------------------------------------------------------- 16 | // Helpers 17 | // ---------------------------------------------------------------------------- 18 | 19 | const getUrl = (url: string, cb) => { 20 | let text = ''; 21 | http.get(url, function (res) { 22 | const { statusCode } = res; 23 | let error; 24 | if (statusCode !== 200) { 25 | res.resume(); 26 | throw new Error(`Request Failed.\n Status Code: ${statusCode}`); 27 | } 28 | 29 | res.setEncoding('utf8'); 30 | res.on('data', (chunk) => { text += chunk; }); 31 | res.on('end', () => { 32 | cb(text); 33 | }); 34 | }); 35 | }; 36 | 37 | // ---------------------------------------------------------------------------- 38 | // Browser-specific implementations of core analyzer constructs. 39 | // ---------------------------------------------------------------------------- 40 | 41 | export class BrowserDocumentManager implements editor.DocumentManager { 42 | public k: string; 43 | public k8s: string; 44 | 45 | // URI utilities. 46 | public readonly backsplicePrefix = `file:///`; 47 | public readonly windowDocUri = `${this.backsplicePrefix}window`; 48 | public readonly k8sUri = `${this.backsplicePrefix}k8s.libsonnet`; 49 | 50 | // The ksonnet files (e.g., `k.libsonnet`) never change. Because 51 | // static analysis of their files is expensive, we assign them 52 | // version 0 so that it's _always_ cached. 53 | public readonly staticsVersion = 0; 54 | 55 | get = ( 56 | file: editor.FileUri | ast.Import | ast.ImportStr, 57 | ): {text: string, version?: number, resolvedPath: string} => { 58 | const fileUri = ast.isImport(file) || ast.isImportStr(file) 59 | ? `${this.backsplicePrefix}${file.file}` 60 | : file; 61 | 62 | if (fileUri === `${this.backsplicePrefix}ksonnet.beta.2/k.libsonnet`) { 63 | return { 64 | text: this.k, 65 | version: this.staticsVersion, 66 | resolvedPath: fileUri, 67 | }; 68 | } else if (fileUri === this.k8sUri) { 69 | return { 70 | text: this.k8s, 71 | version: this.staticsVersion, 72 | resolvedPath: fileUri, 73 | }; 74 | } else if (fileUri === this.windowDocUri) { 75 | return { 76 | text: this.windowText, 77 | version: this.version, 78 | resolvedPath: fileUri, 79 | }; 80 | } 81 | 82 | throw new Error(`Unrecognized file ${fileUri}`); 83 | } 84 | 85 | public setWindowText = (text: string, version?: number) => { 86 | this.windowText = text; 87 | this.version = version; 88 | } 89 | 90 | private windowText: string = ""; 91 | private version?: number = undefined; 92 | } 93 | 94 | export class BrowserCompilerService implements _static.LexicalAnalyzerService { 95 | public cache = ( 96 | fileUri: string, text: string, version?: number 97 | ): _static.ParsedDocument | _static.FailedParsedDocument => { 98 | // 99 | // There are 3 possible outcomes: 100 | // 101 | // 1. We successfully parse the document. Cache. 102 | // 2. We successfully lex but fail to parse. Return 103 | // `PartialParsedDocument`. 104 | // 3. We fail to lex. Return `PartialParsedDocument`. 105 | // 106 | 107 | // Attempt to retrieve cached parse if document versions are the 108 | // same. If version is undefined, it comes from a source that 109 | // doesn't track document version, and we always re-parse. 110 | const tryGet = this.docCache.get(fileUri); 111 | if (tryGet !== undefined && tryGet.version !== undefined && 112 | tryGet.version === version 113 | ) { 114 | return tryGet; 115 | } 116 | 117 | // TODO: Replace this with a URL provider abstraction. 118 | const parsedUrl = url.parse(fileUri); 119 | if (!parsedUrl || !parsedUrl.path) { 120 | throw new Error(`INTERNAL ERROR: Failed to parse URI '${fileUri}'`); 121 | } 122 | 123 | const lex = lexer.Lex(parsedUrl.path, text); 124 | if (lexical.isStaticError(lex)) { 125 | // TODO: emptyTokens is not right. Fill it in. 126 | const fail = new _static.LexFailure(lexer.emptyTokens, lex); 127 | return new _static.FailedParsedDocument(text, fail, version); 128 | } 129 | 130 | const parse = parser.Parse(lex); 131 | if (lexical.isStaticError(parse)) { 132 | const fail = new _static.ParseFailure(lex, parse); 133 | return new _static.FailedParsedDocument(text, fail, version); 134 | } 135 | 136 | const parsedDoc = new _static.ParsedDocument(text, lex, parse, version); 137 | this.docCache = this.docCache.set(fileUri, parsedDoc); 138 | return parsedDoc; 139 | } 140 | 141 | public getLastSuccess = ( 142 | fileUri: string 143 | ): _static.ParsedDocument | null => { 144 | return this.docCache.has(fileUri) && this.docCache.get(fileUri) || null; 145 | } 146 | 147 | public delete = (fileUri: string): void => { 148 | this.docCache = this.docCache.delete(fileUri); 149 | } 150 | 151 | // 152 | // Private members. 153 | // 154 | 155 | private docCache = im.Map(); 156 | } 157 | 158 | // ---------------------------------------------------------------------------- 159 | // Set up analyzer in browser. 160 | // ---------------------------------------------------------------------------- 161 | 162 | const docs = new BrowserDocumentManager(); 163 | const cs = new BrowserCompilerService(); 164 | const analyzer = new _static.Analyzer(docs, cs); 165 | 166 | // ---------------------------------------------------------------------------- 167 | // Get ksonnet files. 168 | // ---------------------------------------------------------------------------- 169 | 170 | getUrl( 171 | 'https://raw.githubusercontent.com/ksonnet/ksonnet-lib/bd6b2d618d6963ea6a81fcc5623900d8ba110a32/ksonnet.beta.2/k.libsonnet', 172 | text => {docs.k = text;}); 173 | getUrl( 174 | "https://raw.githubusercontent.com/ksonnet/ksonnet-lib/bd6b2d618d6963ea6a81fcc5623900d8ba110a32/ksonnet.beta.2/k8s.libsonnet", 175 | text => { 176 | docs.k8s = text; 177 | // Static analysis on `k8s.libsonnet` takes multiple seconds to 178 | // complete, so do this immediately. 179 | cs.cache(docs.k8sUri, text, docs.staticsVersion); 180 | }); 181 | 182 | interface MonacoPosition { 183 | lineNumber: number, 184 | column: number, 185 | }; 186 | 187 | // ---------------------------------------------------------------------------- 188 | // Public functions for the Monaco editor to call. 189 | // ---------------------------------------------------------------------------- 190 | 191 | global.docOnChange = (text: string, version?: number) => { 192 | docs.setWindowText(text, version); 193 | cs.cache(docs.windowDocUri, text, version); 194 | } 195 | 196 | global.onComplete = ( 197 | text: string, position: MonacoPosition 198 | ): Promise => { 199 | return analyzer 200 | .onComplete( 201 | docs.windowDocUri, new lexical.Location(position.lineNumber, position.column)); 202 | } 203 | -------------------------------------------------------------------------------- /syntaxes/jsonnet.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", 3 | "name": "Jsonnet", 4 | "patterns": [ 5 | { 6 | "include": "#expression" 7 | }, 8 | { 9 | "include": "#keywords" 10 | } 11 | ], 12 | "repository": { 13 | "builtin-functions": { 14 | "patterns": [ 15 | { 16 | "match": "\\bstd[.](acos|asin|atan|ceil|char|codepoint|cos|exp|exponent)\\b", 17 | "name": "support.function.jsonnet" 18 | }, 19 | { 20 | "match": "\\bstd[.](filter|floor|force|length|log|makeArray|mantissa)\\b", 21 | "name": "support.function.jsonnet" 22 | }, 23 | { 24 | "match": "\\bstd[.](objectFields|objectHas|pow|sin|sqrt|tan|type|thisFile)\\b", 25 | "name": "support.function.jsonnet" 26 | }, 27 | { 28 | "match": "\\bstd[.](acos|asin|atan|ceil|char|codepoint|cos|exp|exponent)\\b", 29 | "name": "support.function.jsonnet" 30 | }, 31 | { 32 | "match": "\\bstd[.](abs|assertEqual|escapeString(Bash|Dollars|Json|Python))\\b", 33 | "name": "support.function.jsonnet" 34 | }, 35 | { 36 | "match": "\\bstd[.](filterMap|flattenArrays|foldl|foldr|format|join)\\b", 37 | "name": "support.function.jsonnet" 38 | }, 39 | { 40 | "match": "\\bstd[.](lines|manifest(Ini|Python(Vars)?)|map|max|min|mod)\\b", 41 | "name": "support.function.jsonnet" 42 | }, 43 | { 44 | "match": "\\bstd[.](set|set(Diff|Inter|Member|Union)|sort)\\b", 45 | "name": "support.function.jsonnet" 46 | }, 47 | { 48 | "match": "\\bstd[.](range|split|stringChars|substr|toString|uniq)\\b", 49 | "name": "support.function.jsonnet" 50 | } 51 | ] 52 | }, 53 | "comment": { 54 | "patterns": [ 55 | { 56 | "begin": "/\\*", 57 | "end": "\\*/", 58 | "name": "comment.block.jsonnet" 59 | }, 60 | { 61 | "match": "//.*$", 62 | "name": "comment.line.jsonnet" 63 | }, 64 | { 65 | "match": "#.*$", 66 | "name": "comment.block.jsonnet" 67 | } 68 | ] 69 | }, 70 | "double-quoted-strings": { 71 | "begin": "\"", 72 | "end": "\"", 73 | "name": "string.quoted.double.jsonnet", 74 | "patterns": [ 75 | { 76 | "match": "\\\\([\"\\\\/bfnrt]|(u[0-9a-fA-F]{4}))", 77 | "name": "constant.character.escape.jsonnet" 78 | }, 79 | { 80 | "match": "\\\\[^\"\\\\/bfnrtu]", 81 | "name": "invalid.illegal.jsonnet" 82 | } 83 | ] 84 | }, 85 | "expression": { 86 | "patterns": [ 87 | { 88 | "include": "#literals" 89 | }, 90 | { 91 | "include": "#comment" 92 | }, 93 | { 94 | "include": "#single-quoted-strings" 95 | }, 96 | { 97 | "include": "#double-quoted-strings" 98 | }, 99 | { 100 | "include": "#triple-quoted-strings" 101 | }, 102 | { 103 | "include": "#builtin-functions" 104 | }, 105 | { 106 | "include": "#functions" 107 | } 108 | ] 109 | }, 110 | "functions": { 111 | "patterns": [ 112 | { 113 | "begin": "\\b([a-zA-Z_][a-z0-9A-Z_]*)\\s*\\(", 114 | "beginCaptures": { 115 | "1": { 116 | "name": "entity.name.function.jsonnet" 117 | } 118 | }, 119 | "end": "\\)", 120 | "name": "meta.function", 121 | "patterns": [ 122 | { 123 | "include": "#expression" 124 | } 125 | ] 126 | } 127 | ] 128 | }, 129 | "keywords": { 130 | "patterns": [ 131 | { 132 | "match": "[!:~\\+\\-&\\|\\^=<>\\*\\/%]", 133 | "name": "keyword.operator.jsonnet" 134 | }, 135 | { 136 | "match": "\\$", 137 | "name": "keyword.other.jsonnet" 138 | }, 139 | { 140 | "match": "\\b(self|super|import|importstr|local|tailstrict)\\b", 141 | "name": "keyword.other.jsonnet" 142 | }, 143 | { 144 | "match": "\\b(if|then|else|for|in|error|assert)\\b", 145 | "name": "keyword.control.jsonnet" 146 | }, 147 | { 148 | "match": "\\b(function)\\b", 149 | "name": "storage.type.jsonnet" 150 | }, 151 | { 152 | "match": "[a-zA-Z_][a-z0-9A-Z_]*\\s*(:::|\\+:::)", 153 | "name": "variable.parameter.jsonnet" 154 | }, 155 | { 156 | "match": "[a-zA-Z_][a-z0-9A-Z_]*\\s*(::|\\+::)", 157 | "name": "entity.name.type" 158 | }, 159 | { 160 | "match": "[a-zA-Z_][a-z0-9A-Z_]*\\s*(:|\\+:)", 161 | "name": "variable.parameter.jsonnet" 162 | } 163 | ] 164 | }, 165 | "literals": { 166 | "patterns": [ 167 | { 168 | "match": "\\b(true|false|null)\\b", 169 | "name": "constant.language.jsonnet" 170 | }, 171 | { 172 | "match": "\\b(\\d+([Ee][+-]?\\d+)?)\\b", 173 | "name": "constant.numeric.jsonnet" 174 | }, 175 | { 176 | "match": "\\b\\d+[.]\\d*([Ee][+-]?\\d+)?\\b", 177 | "name": "constant.numeric.jsonnet" 178 | }, 179 | { 180 | "match": "\\b[.]\\d+([Ee][+-]?\\d+)?\\b", 181 | "name": "constant.numeric.jsonnet" 182 | } 183 | ] 184 | }, 185 | "single-quoted-strings": { 186 | "begin": "'", 187 | "end": "'", 188 | "name": "string.quoted.double.jsonnet", 189 | "patterns": [ 190 | { 191 | "match": "\\\\(['\\\\/bfnrt]|(u[0-9a-fA-F]{4}))", 192 | "name": "constant.character.escape.jsonnet" 193 | }, 194 | { 195 | "match": "\\\\[^'\\\\/bfnrtu]", 196 | "name": "invalid.illegal.jsonnet" 197 | } 198 | ] 199 | }, 200 | "triple-quoted-strings": { 201 | "patterns": [ 202 | { 203 | "begin": "\\|\\|\\|", 204 | "end": "\\|\\|\\|", 205 | "name": "string.quoted.triple.jsonnet" 206 | } 207 | ] 208 | } 209 | }, 210 | "scopeName": "source.jsonnet" 211 | } 212 | -------------------------------------------------------------------------------- /syntaxes/jsonnet.tmLanguage.jsonnet: -------------------------------------------------------------------------------- 1 | local identifier = "[a-zA-Z_][a-z0-9A-Z_]*"; 2 | 3 | local Include(id) = { include: "#%s" % id }; 4 | 5 | local string = { 6 | local escapeCharsPattern = "\\\\([%s\\\\/bfnrt]|(u[0-9a-fA-F]{4}))", 7 | local illegalCharsPattern = "\\\\[^%s\\\\/bfnrtu]", 8 | 9 | escape:: { 10 | single: escapeCharsPattern % "'", 11 | double: escapeCharsPattern % "\"", 12 | }, 13 | 14 | illegal:: { 15 | single: illegalCharsPattern % "'", 16 | double: illegalCharsPattern % "\"", 17 | }, 18 | }; 19 | 20 | local match = { 21 | Simple(name, match):: { 22 | name: name, 23 | match: match, 24 | }, 25 | 26 | Span(name, begin, end):: { 27 | name: name, 28 | begin: begin, 29 | end: end, 30 | }, 31 | }; 32 | 33 | { 34 | "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", 35 | name: "Jsonnet", 36 | patterns: [ 37 | Include("expression"), 38 | Include("keywords"), 39 | ], 40 | repository: { 41 | expression: { 42 | patterns: [ 43 | Include("literals"), 44 | Include("comment"), 45 | Include("single-quoted-strings"), 46 | Include("double-quoted-strings"), 47 | Include("triple-quoted-strings"), 48 | Include("builtin-functions"), 49 | Include("functions"), 50 | ] 51 | }, 52 | keywords: { 53 | patterns: [ 54 | match.Simple("keyword.operator.jsonnet", "[!:~\\+\\-&\\|\\^=<>\\*\\/%]"), 55 | match.Simple("keyword.other.jsonnet", "\\$"), 56 | match.Simple("keyword.other.jsonnet", "\\b(self|super|import|importstr|local|tailstrict)\\b"), 57 | match.Simple("keyword.control.jsonnet", "\\b(if|then|else|for|in|error|assert)\\b"), 58 | match.Simple("storage.type.jsonnet", "\\b(function)\\b"), 59 | match.Simple("variable.parameter.jsonnet", "%s\\s*(:::|\\+:::)" % identifier), 60 | match.Simple("entity.name.type", "%s\\s*(::|\\+::)" % identifier,), 61 | match.Simple("variable.parameter.jsonnet", "%s\\s*(:|\\+:)" % identifier), 62 | 63 | ] 64 | }, 65 | literals: { 66 | patterns: [ 67 | match.Simple("constant.language.jsonnet", "\\b(true|false|null)\\b"), 68 | match.Simple("constant.numeric.jsonnet", "\\b(\\d+([Ee][+-]?\\d+)?)\\b"), 69 | match.Simple("constant.numeric.jsonnet", "\\b\\d+[.]\\d*([Ee][+-]?\\d+)?\\b"), 70 | match.Simple("constant.numeric.jsonnet", "\\b[.]\\d+([Ee][+-]?\\d+)?\\b"), 71 | ] 72 | }, 73 | "builtin-functions": { 74 | patterns: [ 75 | match.Simple("support.function.jsonnet", "\\bstd[.](acos|asin|atan|ceil|char|codepoint|cos|exp|exponent)\\b"), 76 | match.Simple("support.function.jsonnet", "\\bstd[.](filter|floor|force|length|log|makeArray|mantissa)\\b"), 77 | match.Simple("support.function.jsonnet", "\\bstd[.](objectFields|objectHas|pow|sin|sqrt|tan|type|thisFile)\\b"), 78 | match.Simple("support.function.jsonnet", "\\bstd[.](acos|asin|atan|ceil|char|codepoint|cos|exp|exponent)\\b"), 79 | match.Simple("support.function.jsonnet", "\\bstd[.](abs|assertEqual|escapeString(Bash|Dollars|Json|Python))\\b"), 80 | match.Simple("support.function.jsonnet", "\\bstd[.](filterMap|flattenArrays|foldl|foldr|format|join)\\b"), 81 | match.Simple("support.function.jsonnet", "\\bstd[.](lines|manifest(Ini|Python(Vars)?)|map|max|min|mod)\\b"), 82 | match.Simple("support.function.jsonnet", "\\bstd[.](set|set(Diff|Inter|Member|Union)|sort)\\b"), 83 | match.Simple("support.function.jsonnet", "\\bstd[.](range|split|stringChars|substr|toString|uniq)\\b"), 84 | ] 85 | }, 86 | "single-quoted-strings": 87 | match.Span("string.quoted.double.jsonnet", "'", "'") { 88 | patterns: [ 89 | match.Simple("constant.character.escape.jsonnet", string.escape.single), 90 | match.Simple("invalid.illegal.jsonnet", string.illegal.single), 91 | ] 92 | }, 93 | "double-quoted-strings": 94 | match.Span("string.quoted.double.jsonnet", "\"", "\"") { 95 | patterns: [ 96 | match.Simple("constant.character.escape.jsonnet", string.escape.double), 97 | match.Simple("invalid.illegal.jsonnet", string.illegal.double), 98 | ] 99 | }, 100 | "triple-quoted-strings": { 101 | patterns: [ 102 | match.Span("string.quoted.triple.jsonnet", "\\|\\|\\|", "\\|\\|\\|"), 103 | ] 104 | }, 105 | functions: { 106 | patterns: [ 107 | match.Span("meta.function", "\\b([a-zA-Z_][a-z0-9A-Z_]*)\\s*\\(", "\\)") { 108 | beginCaptures: { 109 | "1": { name: "entity.name.function.jsonnet" } 110 | }, 111 | patterns: [ 112 | Include("expression"), 113 | ], 114 | }, 115 | ] 116 | }, 117 | comment: { 118 | patterns: [ 119 | match.Span("comment.block.jsonnet", "/\\*", "\\*/"), 120 | match.Simple("comment.line.jsonnet", "//.*$"), 121 | match.Simple("comment.block.jsonnet", "#.*$"), 122 | ] 123 | } 124 | }, 125 | scopeName: "source.jsonnet" 126 | } -------------------------------------------------------------------------------- /test/data/simple-import.jsonnet: -------------------------------------------------------------------------------- 1 | local fooModule = import "./simple-import.libsonnet"; 2 | 3 | { 4 | bar: fooModule, 5 | baz: fooModule.foo, 6 | bat: fooModule.bar, 7 | bag: fooModule.baz.bat, 8 | field1: fooModule.testField1, 9 | field2: fooModule.testField2, 10 | field3: fooModule.testField3, 11 | field4: fooModule.testField4, 12 | field5: fooModule.testField5, 13 | field6: fooModule.testField6, 14 | field7: fooModule.testField7, 15 | field8: fooModule.testField8, 16 | field9: fooModule.testField9, 17 | field10: fooModule.testField10, 18 | field11: fooModule.testField11, 19 | field12: fooModule.testField12, 20 | } -------------------------------------------------------------------------------- /test/data/simple-import.libsonnet: -------------------------------------------------------------------------------- 1 | local f = "fakeImport"; 2 | { 3 | // `foo` is a property that has very useful data. 4 | foo: 99, 5 | // `bar` is a local, and comments on top of it should not be 6 | // retrieved. 7 | local bar = 300, 8 | baz: { 9 | // `bat` contains a fancy value, `batVal`. 10 | bat: "batVal", 11 | }, 12 | /* This comment should appear over `testField1`. */ 13 | testField1: "foo", 14 | /* Line 1 of a comment that appears over `testField2`. 15 | * Line 2 of a comment that appears over `testField2`. 16 | */ 17 | testField2: "foo", 18 | /* Not a comment for `testField3`. */ 19 | /* A comment for `testField3`. 20 | */ 21 | testField3: "foo" 22 | /* A comment for `testField4`. */ 23 | , testField4: "foo" 24 | /* Not a comment for `testField5`. */ 25 | , 26 | /* A comment for `testField5`. */ 27 | testField5: "foo", 28 | // Not a comment for `testField6`. 29 | /* A comment for `testField6`. 30 | */ 31 | // A comment for `testField6`. 32 | testField6: "foo", 33 | # A comment for `testField7`. 34 | testField7: "foo", 35 | # Line 1 of a comment for `testField8`. 36 | # Line 2 of a comment for `testField8`. 37 | testField8: "foo" 38 | # A comment for `testField9`. 39 | , testField9: "foo" 40 | # Not a comment for `testField10`. 41 | , 42 | # A comment for `testField10`. 43 | testField10: "foo", 44 | // Not a comment for `testField11`. 45 | 46 | // A comment for `testField11`. 47 | testField11: "foo", 48 | # Not a comment for `testField12`. 49 | 50 | # A comment for `testField12`. 51 | testField12: "foo", 52 | } -------------------------------------------------------------------------------- /test/data/simple-nodes.jsonnet: -------------------------------------------------------------------------------- 1 | { 2 | property1: foo, 3 | property2: 2, 4 | local foo = 3, 5 | local baz = bar.baz, 6 | local bar = {baz: 3}, 7 | local merged1 = {a: 1, b: 2} + {b: 3, c: 4}, 8 | local merged2 = merged1 + {a: 99}, 9 | local merged3 = {a: 99} + merged1, 10 | local merged4 = merged1 + merged2, 11 | useMerged: [merged1.b, merged2.a, merged3.a, merged4.a], 12 | local nestedMerge1 = {merged1: merged1}, 13 | local nestedMerge2 = {merged2: merged2}, 14 | local nestedMerge3 = nestedMerge1.merged1 + nestedMerge2.merged2, 15 | useMerged2: nestedMerge3.a, 16 | local numberVal1 = 1, 17 | local numberVal2 = numberVal1, 18 | number: numberVal2, 19 | } -------------------------------------------------------------------------------- /test/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as vscode from 'vscode'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import * as tmp from 'tmp'; 6 | import * as myExt from '../client/extension'; 7 | 8 | describe("extension tests", () => { 9 | describe("ksonnet", () => { 10 | const dirs = ["/foo/components/ns"]; 11 | const files = ["/foo/app.yaml"]; 12 | 13 | describe("isInApp", () => { 14 | 15 | function withTemp(curPath: string, expected: any) { 16 | tmp.dir((err, rootPath, cleanupCallback) => { 17 | for (let d in dirs) { 18 | const dirPath = path.join(rootPath, d); 19 | fs.mkdirSync(dirPath); 20 | } 21 | 22 | for (let f in files) { 23 | const filePath = path.join(rootPath, f); 24 | fs.writeFileSync(filePath, ''); 25 | } 26 | 27 | const cur = path.join(rootPath, curPath); 28 | const got = myExt.ksonnet.isInApp(cur); 29 | assert.equal(got, expected); 30 | 31 | cleanupCallback(); 32 | }) 33 | } 34 | 35 | it("with a file in components is in a ksonnet app", () => { 36 | withTemp('/foo/components/file.jsonnet', true); 37 | }) 38 | 39 | it("with a file in a component namespace is in a ksonnet app", () => { 40 | withTemp('/foo/components/ns/file.jsonnet', true); 41 | }) 42 | 43 | it("with a file not under the components hierarchy is not in a ksonnet app", () => { 44 | withTemp('/foo/ns/file.jsonnet', false); 45 | }) 46 | 47 | it("with a file in the root directory is not a ksonnet app", () => { 48 | withTemp('/', false); 49 | }) 50 | }) 51 | 52 | describe('rootPath', () => { 53 | 54 | function withTemp(curPath: string, expected: any) { 55 | tmp.dir((err, rootPath, cleanupCallback) => { 56 | for (let d in dirs) { 57 | const dirPath = path.join(rootPath, d); 58 | fs.mkdirSync(dirPath); 59 | } 60 | 61 | for (let f in files) { 62 | const filePath = path.join(rootPath, f); 63 | fs.writeFileSync(filePath, ''); 64 | } 65 | 66 | const cur = path.join(rootPath, curPath); 67 | const got = myExt.ksonnet.rootPath(cur); 68 | assert.equal(got, expected); 69 | 70 | cleanupCallback(); 71 | }) 72 | } 73 | 74 | it("with a file in components is in a ksonnet app", () => { 75 | withTemp('/foo/components/file.jsonnet', '/foo'); 76 | }) 77 | 78 | it("with a file in a component namespace is in a ksonnet app", () => { 79 | withTemp('/foo/components/ns/file.jsonnet', '/foo'); 80 | }) 81 | 82 | it("with a file not under the components hierarchy is not in a ksonnet app", () => { 83 | withTemp('/foo/ns/file.jsonnet', ''); 84 | }) 85 | 86 | it("with a file in the root directory is not a ksonnet app", () => { 87 | withTemp('/', ''); 88 | }) 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING 3 | // 4 | // This file is providing the test runner to use when running extension tests. 5 | // By default the test runner in use is Mocha based. 6 | // 7 | // You can provide your own test runner if you want to override it by exporting 8 | // a function run(testRoot: string, clb: (error:Error) => void) that the extension 9 | // host can call to run the tests. The test runner is expected to use console.log 10 | // to report the results back to the caller. When the tests are finished, return 11 | // a possible error to the callback or null if none. 12 | 13 | import * as testRunner from 'vscode/lib/testrunner'; 14 | 15 | // You can directly control Mocha options by uncommenting the following lines 16 | // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info 17 | testRunner.configure({ 18 | ui: 'bdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) 19 | useColors: true // colored output from test results 20 | }); 21 | 22 | module.exports = testRunner; 23 | -------------------------------------------------------------------------------- /test/server/ast/lexer_tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, assert } from 'chai'; 2 | 3 | import * as im from 'immutable'; 4 | 5 | import * as lexical from '../../../compiler/lexical-analysis/lexical'; 6 | import * as lexer from '../../../compiler/lexical-analysis/lexer'; 7 | 8 | interface lexTest { 9 | name: string 10 | input: string 11 | tokens: lexer.Token[] 12 | errString: string 13 | }; 14 | 15 | const makeLexTest = 16 | (name: string, input: string, tokens: lexer.Token[], errString: string) => { 17 | name: name, 18 | input: input, 19 | tokens: tokens, 20 | errString: errString, 21 | }; 22 | 23 | const emptyTokenArray = (): lexer.Token[] => []; 24 | 25 | const tEOF = new lexer.Token( 26 | "TokenEndOfFile", [], "", "", "", lexical.MakeLocationRangeMessage("")); 27 | 28 | const makeToken = 29 | (kind: lexer.TokenKind, data: string, locRange: lexical.LocationRange) => 30 | new lexer.Token(kind, [], data, "", "", locRange); 31 | 32 | const makeLocRange = 33 | (beginLine: number, beginCol: number, endLine: number, endCol : number) => 34 | { 35 | begin: new lexical.Location(beginLine, beginCol), 36 | end: new lexical.Location(endLine, endCol), 37 | } 38 | 39 | const lexTests = [ 40 | makeLexTest("empty", "", emptyTokenArray(), ""), 41 | makeLexTest("whitespace", " \t\n\r\r\n", emptyTokenArray(), ""), 42 | 43 | makeLexTest("brace L", "{", [makeToken("TokenBraceL", "{", makeLocRange(1, 1, 1, 2))], ""), 44 | makeLexTest("brace R", "}", [makeToken("TokenBraceR", "}", makeLocRange(1, 1, 1, 2))], ""), 45 | makeLexTest("bracket L", "[", [makeToken("TokenBracketL", "[", makeLocRange(1, 1, 1, 2))], ""), 46 | makeLexTest("bracket R", "]", [makeToken("TokenBracketR", "]", makeLocRange(1, 1, 1, 2))], ""), 47 | makeLexTest("colon", ":", [makeToken("TokenOperator", ":", makeLocRange(1, 1, 1, 2))], ""), 48 | makeLexTest("colon2", "::", [makeToken("TokenOperator", "::", makeLocRange(1, 1, 1, 3))], ""), 49 | makeLexTest("colon3", ":::", [makeToken("TokenOperator", ":::", makeLocRange(1, 1, 1, 4))], ""), 50 | makeLexTest("arrow right", "->", [makeToken("TokenOperator", "->", makeLocRange(1, 1, 1, 3))], ""), 51 | makeLexTest("less than minus", "<-", [makeToken("TokenOperator", "<", makeLocRange(1, 1, 1, 2)), 52 | makeToken("TokenOperator", "-", makeLocRange(1, 2, 1, 3))], ""), 53 | makeLexTest("comma", ",", [makeToken("TokenComma", ",", makeLocRange(1, 1, 1, 2))], ""), 54 | makeLexTest("dollar", "$", [makeToken("TokenDollar", "$", makeLocRange(1, 1, 1, 2))], ""), 55 | makeLexTest("dot", ".", [makeToken("TokenDot", ".", makeLocRange(1, 1, 1, 2))], ""), 56 | makeLexTest("paren L", "(", [makeToken("TokenParenL", "(", makeLocRange(1, 1, 1, 2))], ""), 57 | makeLexTest("paren R", ")", [makeToken("TokenParenR", ")", makeLocRange(1, 1, 1, 2))], ""), 58 | makeLexTest("semicolon", ";", [makeToken("TokenSemicolon", ";", makeLocRange(1, 1, 1, 2))], ""), 59 | 60 | makeLexTest("not 1", "!", [makeToken("TokenOperator", "!", makeLocRange(1, 1, 1, 2))], ""), 61 | makeLexTest("not 2", "! ", [makeToken("TokenOperator", "!", makeLocRange(1, 1, 1, 2))], ""), 62 | makeLexTest("not equal", "!=", [makeToken("TokenOperator", "!=", makeLocRange(1, 1, 1, 3))], ""), 63 | makeLexTest("tilde", "~", [makeToken("TokenOperator", "~", makeLocRange(1, 1, 1, 2))], ""), 64 | makeLexTest("plus", "+", [makeToken("TokenOperator", "+", makeLocRange(1, 1, 1, 2))], ""), 65 | makeLexTest("minus", "-", [makeToken("TokenOperator", "-", makeLocRange(1, 1, 1, 2))], ""), 66 | 67 | makeLexTest("number 0", "0", [makeToken("TokenNumber", "0", makeLocRange(1, 1, 1, 2))], ""), 68 | makeLexTest("number 1", "1", [makeToken("TokenNumber", "1", makeLocRange(1, 1, 1, 2))], ""), 69 | makeLexTest("number 1.0", "1.0", [makeToken("TokenNumber", "1.0", makeLocRange(1, 1, 1, 4))], ""), 70 | makeLexTest("number 0.10", "0.10", [makeToken("TokenNumber", "0.10", makeLocRange(1, 1, 1, 5))], ""), 71 | makeLexTest("number 0e100", "0e100", [makeToken("TokenNumber", "0e100", makeLocRange(1, 1, 1, 6))], ""), 72 | makeLexTest("number 1e100", "1e100", [makeToken("TokenNumber", "1e100", makeLocRange(1, 1, 1, 6))], ""), 73 | makeLexTest("number 1.1e100", "1.1e100", [makeToken("TokenNumber", "1.1e100", makeLocRange(1, 1, 1, 8))], ""), 74 | makeLexTest("number 1.1e-100", "1.1e-100", [makeToken("TokenNumber", "1.1e-100", makeLocRange(1, 1, 1, 9))], ""), 75 | makeLexTest("number 1.1e+100", "1.1e+100", [makeToken("TokenNumber", "1.1e+100", makeLocRange(1, 1, 1, 9))], ""), 76 | makeLexTest("number 0100", "0100", [ 77 | makeToken("TokenNumber", "0", makeLocRange(1, 1, 1, 2)), 78 | makeToken("TokenNumber", "100", makeLocRange(1, 2, 1, 5)), 79 | ], ""), 80 | makeLexTest("number 10+10", "10+10", [ 81 | makeToken("TokenNumber", "10", makeLocRange(1, 1, 1, 3)), 82 | makeToken("TokenOperator", "+", makeLocRange(1, 3, 1, 4)), 83 | makeToken("TokenNumber", "10", makeLocRange(1, 4, 1, 6)), 84 | ], ""), 85 | makeLexTest("number 1.+3", "1.+3", emptyTokenArray(), "number 1.+3:1:3 Couldn't lex number, junk after decimal point: '+'"), 86 | makeLexTest("number 1e!", "1e!", emptyTokenArray(), "number 1e!:1:3 Couldn't lex number, junk after 'E': '!'"), 87 | makeLexTest("number 1e+!", "1e+!", emptyTokenArray(), "number 1e+!:1:4 Couldn't lex number, junk after exponent sign: '!'"), 88 | 89 | makeLexTest("double string \"hi\"", "\"hi\"", [makeToken("TokenStringDouble", "hi", makeLocRange(1, 2, 1, 4))], ""), 90 | makeLexTest("double string \"hi nl\"", "\"hi\n\"", [makeToken("TokenStringDouble", "hi\n", makeLocRange(1, 2, 2, 1))], ""), 91 | makeLexTest("double string \"hi\\\"\"", "\"hi\\\"\"", [makeToken("TokenStringDouble", "hi\\\"", makeLocRange(1, 2, 1, 6))], ""), 92 | makeLexTest("double string \"hi\\nl\"", "\"hi\\\n\"", [makeToken("TokenStringDouble", "hi\\\n", makeLocRange(1, 2, 2, 1))], ""), 93 | makeLexTest("double string \"hi", "\"hi", emptyTokenArray(), "double string \"hi:1:1 Unterminated String"), 94 | 95 | makeLexTest("single string 'hi'", "'hi'", [makeToken("TokenStringSingle", "hi", makeLocRange(1, 2, 1, 4))], ""), 96 | makeLexTest("single string 'hi nl'", "'hi\n'", [makeToken("TokenStringSingle", "hi\n", makeLocRange(1, 2, 2, 1))], ""), 97 | makeLexTest("single string 'hi\\''", "'hi\\''", [makeToken("TokenStringSingle", "hi\\'", makeLocRange(1, 2, 1, 6))], ""), 98 | makeLexTest("single string 'hi\\nl'", "'hi\\\n'", [makeToken("TokenStringSingle", "hi\\\n", makeLocRange(1, 2, 2, 1))], ""), 99 | makeLexTest("single string 'hi", "'hi", emptyTokenArray(), "single string 'hi:1:1 Unterminated String"), 100 | 101 | makeLexTest("assert", "assert", [makeToken("TokenAssert", "assert", makeLocRange(1, 1, 1, 7))], ""), 102 | makeLexTest("else", "else", [makeToken("TokenElse", "else", makeLocRange(1, 1, 1, 5))], ""), 103 | makeLexTest("error", "error", [makeToken("TokenError", "error", makeLocRange(1, 1, 1, 6))], ""), 104 | makeLexTest("false", "false", [makeToken("TokenFalse", "false", makeLocRange(1, 1, 1, 6))], ""), 105 | makeLexTest("for", "for", [makeToken("TokenFor", "for", makeLocRange(1, 1, 1, 4))], ""), 106 | makeLexTest("function", "function", [makeToken("TokenFunction", "function", makeLocRange(1, 1, 1, 9))], ""), 107 | makeLexTest("if", "if", [makeToken("TokenIf", "if", makeLocRange(1, 1, 1, 3))], ""), 108 | makeLexTest("import", "import", [makeToken("TokenImport", "import", makeLocRange(1, 1, 1, 7))], ""), 109 | makeLexTest("importstr", "importstr", [makeToken("TokenImportStr", "importstr", makeLocRange(1, 1, 1, 10))], ""), 110 | makeLexTest("in", "in", [makeToken("TokenIn", "in", makeLocRange(1, 1, 1, 3))], ""), 111 | makeLexTest("local", "local", [makeToken("TokenLocal", "local", makeLocRange(1, 1, 1, 6))], ""), 112 | makeLexTest("null", "null", [makeToken("TokenNullLit", "null", makeLocRange(1, 1, 1, 5))], ""), 113 | makeLexTest("self", "self", [makeToken("TokenSelf", "self", makeLocRange(1, 1, 1, 5))], ""), 114 | makeLexTest("super", "super", [makeToken("TokenSuper", "super", makeLocRange(1, 1, 1, 6))], ""), 115 | makeLexTest("tailstrict", "tailstrict", [makeToken("TokenTailStrict", "tailstrict", makeLocRange(1, 1, 1, 11))], ""), 116 | makeLexTest("then", "then", [makeToken("TokenThen", "then", makeLocRange(1, 1, 1, 5))], ""), 117 | makeLexTest("true", "true", [makeToken("TokenTrue", "true", makeLocRange(1, 1, 1, 5))], ""), 118 | 119 | makeLexTest("identifier", "foobar123", [makeToken("TokenIdentifier", "foobar123", makeLocRange(1, 1, 1, 10))], ""), 120 | makeLexTest("identifier", "foo bar123", [makeToken("TokenIdentifier", "foo", makeLocRange(1, 1, 1, 4)), makeToken("TokenIdentifier", "bar123", makeLocRange(1, 5, 1, 11))], ""), 121 | 122 | makeLexTest("c++ comment", "// hi", [makeToken("TokenCommentCpp", " hi", makeLocRange(1, 3, 1, 6))], ""), 123 | makeLexTest("hash comment", "# hi", [makeToken("TokenCommentHash", " hi", makeLocRange(1, 2, 1, 5))], ""), 124 | makeLexTest("c comment", "/* hi */", [makeToken("TokenCommentC", " hi ", makeLocRange(1, 3, 1, 7))], ""), 125 | makeLexTest("c comment no term", "/* hi", emptyTokenArray(), "c comment no term:1:1 Multi-line comment has no terminating */"), 126 | 127 | makeLexTest( 128 | "block string spaces", 129 | "|||\ 130 | \n test\ 131 | \n more\ 132 | \n |||\ 133 | \n foo\ 134 | \n|||", 135 | [ 136 | new lexer.Token( 137 | "TokenStringBlock", 138 | [], 139 | "test\n more\n|||\n foo\n", 140 | " ", 141 | "", 142 | makeLocRange(1, 1, 6, 4)) 143 | ], 144 | "", 145 | ), 146 | 147 | makeLexTest( 148 | "block string tabs", 149 | "|||\ 150 | \n test\ 151 | \n more\ 152 | \n |||\ 153 | \n foo\ 154 | \n|||", 155 | [ 156 | new lexer.Token( 157 | "TokenStringBlock", 158 | [], 159 | "test\n more\n|||\n foo\n", 160 | "\t", 161 | "", 162 | makeLocRange(1, 1, 6, 4)) 163 | ], 164 | "" 165 | ), 166 | 167 | makeLexTest( 168 | "block string mixed", 169 | "|||\ 170 | \n test\ 171 | \n more\ 172 | \n |||\ 173 | \n foo\ 174 | \n|||", 175 | [ 176 | new lexer.Token( 177 | "TokenStringBlock", 178 | [], 179 | "test\n more\n|||\n foo\n", 180 | "\t \t", 181 | "", 182 | makeLocRange(1, 1, 6, 4)) 183 | ], 184 | "" 185 | ), 186 | 187 | makeLexTest( 188 | "block string blanks", 189 | "|||\ 190 | \n\ 191 | \n test\ 192 | \n\ 193 | \n\ 194 | \n more\ 195 | \n |||\ 196 | \n foo\ 197 | \n|||", 198 | [ 199 | new lexer.Token( 200 | "TokenStringBlock", 201 | [], 202 | "\ntest\n\n\n more\n|||\n foo\n", 203 | " ", 204 | "", 205 | makeLocRange(1, 1, 9, 4)) 206 | ], 207 | "" 208 | ), 209 | 210 | makeLexTest( 211 | "block string bad indent", 212 | "|||\ 213 | \n test\ 214 | \n foo\ 215 | \n|||", 216 | [], 217 | "block string bad indent:1:1 Text block not terminated with |||" 218 | ), 219 | 220 | makeLexTest( 221 | "block string eof", 222 | "|||\ 223 | \n test", 224 | [], 225 | "block string eof:1:1 Unexpected EOF" 226 | ), 227 | 228 | makeLexTest( 229 | "block string not term", 230 | "|||\ 231 | \n test\ 232 | \n", 233 | [], 234 | "block string not term:1:1 Text block not terminated with |||" 235 | ), 236 | 237 | makeLexTest( 238 | "block string no ws", 239 | "|||\ 240 | \ntest\ 241 | \n|||", 242 | [], 243 | "block string no ws:1:1 Text block's first line must start with whitespace" 244 | ), 245 | 246 | makeLexTest( 247 | "Invalid multiline object", 248 | "{\ 249 | \nbar\ 250 | \n}", 251 | [ 252 | makeToken("TokenBraceL", "{", makeLocRange(1, 1, 1, 2)), 253 | makeToken("TokenIdentifier", "bar", makeLocRange(2, 1, 2, 4)), 254 | makeToken("TokenBraceR", "}", makeLocRange(3, 1, 3, 2)), 255 | ], 256 | "" 257 | ), 258 | 259 | makeLexTest("op *", "*", [makeToken("TokenOperator", "*", makeLocRange(1, 1, 1, 2))], ""), 260 | makeLexTest("op /", "/", [makeToken("TokenOperator", "/", makeLocRange(1, 1, 1, 2))], ""), 261 | makeLexTest("op %", "%", [makeToken("TokenOperator", "%", makeLocRange(1, 1, 1, 2))], ""), 262 | makeLexTest("op &", "&", [makeToken("TokenOperator", "&", makeLocRange(1, 1, 1, 2))], ""), 263 | makeLexTest("op |", "|", [makeToken("TokenOperator", "|", makeLocRange(1, 1, 1, 2))], ""), 264 | makeLexTest("op ^", "^", [makeToken("TokenOperator", "^", makeLocRange(1, 1, 1, 2))], ""), 265 | makeLexTest("op =", "=", [makeToken("TokenOperator", "=", makeLocRange(1, 1, 1, 2))], ""), 266 | makeLexTest("op <", "<", [makeToken("TokenOperator", "<", makeLocRange(1, 1, 1, 2))], ""), 267 | makeLexTest("op >", ">", [makeToken("TokenOperator", ">", makeLocRange(1, 1, 1, 2))], ""), 268 | makeLexTest("op >==|", ">==|", [makeToken("TokenOperator", ">==|", makeLocRange(1, 1, 1, 5))], ""), 269 | 270 | makeLexTest("junk", "💩", emptyTokenArray(), "junk:1:1 Could not lex the character '💩'"), 271 | ]; 272 | 273 | const locationsEqual = (t1: lexer.Token, t2: lexer.Token): boolean => { 274 | if (t1.loc.begin.line != t2.loc.begin.line) { 275 | return false 276 | } 277 | if (t1.loc.begin.column != t2.loc.begin.column) { 278 | return false 279 | } 280 | if (t1.loc.end.line != t2.loc.end.line) { 281 | return false 282 | } 283 | if (t1.loc.end.column != t2.loc.end.column) { 284 | return false 285 | } 286 | 287 | return true 288 | } 289 | 290 | const tokensStreamsEqual = (ts1: lexer.Token[], ts2: lexer.Token[]): boolean => { 291 | if (ts1.length != ts2.length) { 292 | return false 293 | } 294 | for (let i in ts1) { 295 | const t1 = ts1[i]; 296 | const t2 = ts2[i]; 297 | if (t1.kind != t2.kind) { 298 | return false 299 | } 300 | if (t1.data != t2.data) { 301 | return false 302 | } 303 | if (t1.stringBlockIndent != t2.stringBlockIndent) { 304 | return false 305 | } 306 | if (t1.stringBlockTermIndent != t2.stringBlockTermIndent) { 307 | return false 308 | } 309 | 310 | // EOF token is appended in the test loop, so we ignore its 311 | // location. 312 | if (t1.kind != "TokenEndOfFile" && !locationsEqual(t1, t2)) { 313 | return false 314 | } 315 | } 316 | return true 317 | } 318 | 319 | describe("Lexer tests", () => { 320 | for (let test of lexTests) { 321 | it(test.name, () => { 322 | // Copy the test tokens and append an EOF token 323 | const testTokens = im.List(test.tokens).push(tEOF); 324 | const tokens = lexer.Lex(test.name, test.input); 325 | var errString = ""; 326 | if (lexical.isStaticError(tokens)) { 327 | errString = tokens.Error(); 328 | } 329 | assert.equal(errString, test.errString); 330 | 331 | if (!lexical.isStaticError(tokens)) { 332 | const tokenStreamText = tokens 333 | .map(token => { 334 | if (token === undefined) { 335 | throw new Error(`Tried to pretty-print token stream, but there was an undefined token`); 336 | } 337 | return token.toString(); 338 | }) 339 | .join(", "); 340 | 341 | const testTokensText = testTokens 342 | .map(token => { 343 | if (token === undefined) { 344 | throw new Error(`Tried to pretty-print token stream, but there was an undefined token`); 345 | } 346 | return token.toString(); 347 | }) 348 | .join(", "); 349 | assert.isTrue( 350 | tokensStreamsEqual(tokens.toArray(), testTokens.toArray()), 351 | `got\n\t${tokenStreamText}\nexpected\n\t${testTokensText}`); 352 | } 353 | }); 354 | } 355 | }); 356 | 357 | describe("UTF-8 lexer tests", () => { 358 | it("Correctly advances when given a UTF-8 string", () => { 359 | const l = new lexer.lexer("tests", "日本語"); 360 | 361 | let r = l.next(); 362 | assert.equal(r.data, "日"); 363 | assert.equal(r.data.length, 1); 364 | assert.equal(r.codePoint, 26085); 365 | 366 | r = l.next(); 367 | assert.equal(r.data, "本"); 368 | assert.equal(r.data.length, 1); 369 | assert.equal(r.codePoint, 26412); 370 | 371 | r = l.next(); 372 | assert.equal(r.data, "語"); 373 | assert.equal(r.data.length, 1); 374 | assert.equal(r.codePoint, 35486); 375 | }); 376 | 377 | it("Correctly advances when given multi-byte UTF-8 characters", () => { 378 | const l = new lexer.lexer("tests", "𝟘𝟙"); 379 | 380 | let r = l.next(); 381 | assert.equal(r.data, "𝟘"); 382 | assert.equal(r.data.length, 2); 383 | assert.equal(r.codePoint, 120792); 384 | 385 | r = l.next(); 386 | assert.equal(r.data, "𝟙"); 387 | assert.equal(r.data.length, 2); 388 | assert.equal(r.codePoint, 120793); 389 | }); 390 | 391 | it("Runes correctly parse multi-byte UTF-8 characters", () => { 392 | const unicodeRune0 = lexer.runeFromString("𝟘𝟙", 0); 393 | assert.equal(unicodeRune0.data, "𝟘"); 394 | 395 | const unicodeRune1 = lexer.runeFromString("𝟘𝟙", 1); 396 | assert.equal(unicodeRune1.data, "𝟙"); 397 | }); 398 | }); 399 | 400 | describe("Lexer helper tests", () => { 401 | const toRunes = (str: string): lexer.rune[] => { 402 | const codePoints = lexer.makeCodePoints(str); 403 | return codePoints 404 | .map((char, i) => { 405 | if (i === undefined) { 406 | throw new Error("Index can't be undefined in a `map`"); 407 | } 408 | return lexer.runeFromCodePoints(codePoints, i) 409 | }) 410 | .toArray(); 411 | } 412 | 413 | const upperAlpha = toRunes("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); 414 | const lowerAlpha = toRunes("abcdefghijklmnopqrstuvwxyz"); 415 | const digits = toRunes("0123456789"); 416 | 417 | it("`isUpper` correctly reports if character is in [A-Z]", () => { 418 | for (let char of upperAlpha) { 419 | assert.isTrue(lexer.isUpper(char)); 420 | } 421 | 422 | for (let char of lowerAlpha) { 423 | assert.isFalse(lexer.isUpper(char)); 424 | } 425 | 426 | // The character right before range `[A-Z]` in ASCII table. 427 | assert.isFalse(lexer.isUpper(lexer.runeFromString("@", 0))); 428 | 429 | // The character right after range `[A-Z]` in ASCII table. 430 | assert.isFalse(lexer.isUpper(lexer.runeFromString("[", 0))); 431 | }); 432 | 433 | it("`isLower` correctly reports if character is in [a-z]", () => { 434 | for (let char of lowerAlpha) { 435 | assert.isTrue(lexer.isLower(char)); 436 | } 437 | 438 | for (let char of upperAlpha) { 439 | assert.isFalse(lexer.isLower(char)); 440 | } 441 | 442 | // The character right before range `[a-z]` in ASCII table. 443 | assert.isFalse(lexer.isLower(lexer.runeFromString("`", 0))); 444 | 445 | // The character right after range `[a-z]` in ASCII table. 446 | assert.isFalse(lexer.isLower(lexer.runeFromString("{", 0))); 447 | }); 448 | 449 | it("`isNumber` correctly reports if character is in [0-9]", () => { 450 | for (let char of digits) { 451 | assert.isTrue(lexer.isNumber(char)); 452 | } 453 | 454 | for (let char of upperAlpha) { 455 | assert.isFalse(lexer.isNumber(char)); 456 | } 457 | 458 | // The character right before range `[0-9]` in ASCII table. 459 | assert.isFalse(lexer.isNumber(lexer.runeFromString("/", 0))); 460 | 461 | // The character right after range `[0-9]` in ASCII table. 462 | assert.isFalse(lexer.isNumber(lexer.runeFromString(":", 0))); 463 | }); 464 | }); 465 | 466 | describe("Jsonnet error location parsing", () => { 467 | const testSuccessfulParse = (loc: string): lexical.LocationRange => { 468 | const lr = lexical.LocationRange.fromString("", loc); 469 | assert.isNotNull(lr); 470 | return lr; 471 | } 472 | 473 | const testFailedParse = (loc: string): void => { 474 | const lr = lexical.LocationRange.fromString("", loc); 475 | assert.isNull(lr); 476 | } 477 | 478 | it("Correctly parse simple range", () => { 479 | const lr = testSuccessfulParse("(8:15)-(10:1)"); 480 | assert.isNotNull(lr); 481 | assert.equal(lr.begin.line, 8); 482 | assert.equal(lr.begin.column, 15); 483 | assert.equal(lr.end.line, 10); 484 | assert.equal(lr.end.column, 1); 485 | }); 486 | 487 | it("Correctly parse simple single-line range", () => { 488 | const lr = testSuccessfulParse("9:10-19"); 489 | assert.isNotNull(lr); 490 | assert.equal(lr.begin.line, 9); 491 | assert.equal(lr.begin.column, 10); 492 | assert.equal(lr.end.line, 9); 493 | assert.equal(lr.end.column, 19); 494 | }); 495 | 496 | it("Correctly parse simple single-character range", () => { 497 | const lr = testSuccessfulParse("100:2"); 498 | assert.isNotNull(lr); 499 | assert.equal(lr.begin.line, 100); 500 | assert.equal(lr.begin.column, 2); 501 | assert.equal(lr.end.line, 100); 502 | assert.equal(lr.end.column, 2); 503 | }); 504 | 505 | it("Correctly parse simple single-character range with parens", () => { 506 | const lr = testSuccessfulParse("(112:21)"); 507 | assert.isNotNull(lr); 508 | assert.equal(lr.begin.line, 112); 509 | assert.equal(lr.begin.column, 21); 510 | assert.equal(lr.end.line, 112); 511 | assert.equal(lr.end.column, 21); 512 | }); 513 | }); 514 | -------------------------------------------------------------------------------- /test/server/ast/parser_tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, assert } from 'chai'; 2 | 3 | import * as lexer from '../../../compiler/lexical-analysis/lexer'; 4 | import * as lexical from '../../../compiler/lexical-analysis/lexical'; 5 | import * as parser from '../../../compiler/lexical-analysis/parser'; 6 | 7 | const tests = [ 8 | `true`, 9 | `1`, 10 | `1.2e3`, 11 | `!true`, 12 | `null`, 13 | 14 | `$.foo.bar`, 15 | `self.foo.bar`, 16 | `super.foo.bar`, 17 | `super[1]`, 18 | `error "Error!"`, 19 | 20 | `"world"`, 21 | `'world'`, 22 | "|||\ 23 | \n world\ 24 | \n|||", 25 | 26 | `foo(bar)`, 27 | `foo(bar=99)`, 28 | `foo(bar) tailstrict`, 29 | `foo.bar`, 30 | `foo[bar]`, 31 | 32 | `true || false`, 33 | `0 && 1 || 0`, 34 | `0 && (1 || 0)`, 35 | 36 | `local foo = "bar"; foo`, 37 | `local foo(bar) = bar; foo(1)`, 38 | `local foo(bar=4) = bar; foo(1)`, 39 | `{ local foo = "bar", baz: 1}`, 40 | `{ local foo(bar) = bar, baz: foo(1)}`, 41 | `{ local foo(bar=[1]) = bar, baz: foo(1)}`, 42 | 43 | `{ foo(bar, baz): bar+baz }`, 44 | `{ foo(bar={a:1}):: bar }`, 45 | 46 | `{ ["foo" + "bar"]: 3 }`, 47 | `{ ["field" + x]: x for x in [1, 2, 3] }`, 48 | `{ local y = x, ["field" + x]: x for x in [1, 2, 3] }`, 49 | `{ ["field" + x]: x for x in [1, 2, 3] if x <= 2 }`, 50 | `{ ["field" + x + y]: x + y for x in [1, 2, 3] if x <= 2 for y in [4, 5, 6]}`, 51 | 52 | `[]`, 53 | `[a, b, c]`, 54 | `[x for x in [1,2,3] ]`, 55 | `[x for x in [1,2,3] if x <= 2]`, 56 | `[x+y for x in [1,2,3] if x <= 2 for y in [4, 5, 6]]`, 57 | 58 | `{}`, 59 | `{ hello: "world" }`, 60 | `{ hello +: "world" }`, 61 | "{\ 62 | \n hello: \"world\",\ 63 | \n \"name\":: joe,\ 64 | \n 'mood'::: \"happy\",\ 65 | \n |||\ 66 | \n key type\ 67 | \n|||: \"block\",\ 68 | \n}", 69 | 70 | `assert true: 'woah!'; true`, 71 | `{ assert true: 'woah!', foo: bar }`, 72 | 73 | `if n > 1 then 'foos' else 'foo'`, 74 | 75 | `local foo = function(x) x + 1; true`, 76 | 77 | `import 'foo.jsonnet'`, 78 | `importstr 'foo.text'`, 79 | 80 | `{a: b} + {c: d}`, 81 | `{a: b}{c: d}`, 82 | ] 83 | 84 | describe("Successfully parsing text", () => { 85 | for (let s of tests) { 86 | it(`${JSON.stringify(s)}`, () => { 87 | const tokens = lexer.Lex("test", s); 88 | if (lexical.isStaticError(tokens)) { 89 | throw new Error(`Unexpected lexer to emit tokens\n input: ${s}`); 90 | } 91 | 92 | const parse = parser.Parse(tokens); 93 | if (lexical.isStaticError(parse)) { 94 | throw new Error( 95 | `Unexpected parse error\n input: ${s}\n error: ${parse.Error()}`); 96 | } 97 | }); 98 | } 99 | }); 100 | 101 | interface testError { 102 | readonly input: string 103 | readonly err: string 104 | } 105 | 106 | const makeTestError = (input: string, err: string): testError => { 107 | return { 108 | input: input, 109 | err: err, 110 | }; 111 | } 112 | 113 | const errorTests: testError[] = [ 114 | makeTestError(`function(a, b c)`, `test:1:15-16 Expected a comma before next function parameter.`), 115 | makeTestError(`function(a, 1)`, `test:1:13-14 Expected simple identifier but got a complex expression.`), 116 | makeTestError(`a b`, `test:1:3-4 Did not expect: (IDENTIFIER, "b")`), 117 | makeTestError(`foo(a, bar(a b))`, `test:1:14-15 Expected a comma before next function argument.`), 118 | 119 | makeTestError(`local`, `test:1:6 Expected token IDENTIFIER but got end of file`), 120 | makeTestError(`local foo = 1, foo = 2; true`, `test:1:16-19 Duplicate local var: foo`), 121 | makeTestError(`local foo(a b) = a; true`, `test:1:13-14 Expected a comma before next function parameter.`), 122 | makeTestError(`local foo(a): a; true`, `test:1:13-14 Expected operator = but got ":"`), 123 | makeTestError(`local foo(a) = bar(a b); true`, `test:1:22-23 Expected a comma before next function argument.`), 124 | makeTestError(`local foo: 1; true`, `test:1:10-11 Expected operator = but got ":"`), 125 | makeTestError(`local foo = bar(a b); true`, `test:1:19-20 Expected a comma before next function argument.`), 126 | 127 | makeTestError(`{a b}`, `test:1:4-5 Expected token OPERATOR but got (IDENTIFIER, "b")`), 128 | makeTestError(`{a = b}`, `test:1:4-5 Expected one of :, ::, :::, +:, +::, +:::, got: =`), 129 | makeTestError(`{a :::: b}`, `test:1:4-8 Expected one of :, ::, :::, +:, +::, +:::, got: ::::`), 130 | 131 | makeTestError(`{assert x for x in [1, 2, 3]}`, `test:1:11-14 Object comprehension cannot have asserts.`), 132 | makeTestError(`{['foo' + x]: true, [x]: x for x in [1, 2, 3]}`, `test:1:28-31 Object comprehension can only have one field.`), 133 | makeTestError(`{foo: x for x in [1, 2, 3]}`, `test:1:9-12 Object comprehensions can only have [e] fields.`), 134 | makeTestError(`{[x]:: true for x in [1, 2, 3]}`, `test:1:13-16 Object comprehensions cannot have hidden fields.`), 135 | makeTestError(`{[x]: true for 1 in [1, 2, 3]}`, `test:1:16-17 Expected token IDENTIFIER but got (NUMBER, "1")`), 136 | makeTestError(`{[x]: true for x at [1, 2, 3]}`, `test:1:18-20 Expected token in but got (IDENTIFIER, "at")`), 137 | makeTestError(`{[x]: true for x in [1, 2 3]}`, `test:1:27-28 Expected a comma before next array element.`), 138 | makeTestError(`{[x]: true for x in [1, 2, 3] if (a b)}`, `test:1:37-38 Expected token ")" but got (IDENTIFIER, "b")`), 139 | makeTestError(`{[x]: true for x in [1, 2, 3] if a b}`, `test:1:36-37 Expected for, if or "}" after for clause, got: (IDENTIFIER, "b")`), 140 | 141 | makeTestError(`{a: b c:d}`, `test:1:7-8 Expected a comma before next field.`), 142 | 143 | makeTestError(`{[(x y)]: z}`, `test:1:6-7 Expected token ")" but got (IDENTIFIER, "y")`), 144 | makeTestError(`{[x y]: z}`, `test:1:5-6 Expected token "]" but got (IDENTIFIER, "y")`), 145 | 146 | makeTestError(`{foo(x y): z}`, `test:1:8-9 Expected a comma before next method parameter.`), 147 | makeTestError(`{foo(x)+: z}`, `test:1:2-5 Cannot use +: syntax sugar in a method: foo`), 148 | makeTestError(`{foo: 1, foo: 2}`, `test:1:10-13 Duplicate field: foo`), 149 | makeTestError(`{foo: (1 2)}`, `test:1:10-11 Expected token ")" but got (NUMBER, "2")`), 150 | 151 | makeTestError(`{local 1 = 3, true}`, `test:1:8-9 Expected token IDENTIFIER but got (NUMBER, "1")`), 152 | makeTestError(`{local foo = 1, local foo = 2, true}`, `test:1:23-26 Duplicate local var: foo`), 153 | makeTestError(`{local foo(a b) = 1, a: true}`, `test:1:14-15 Expected a comma before next function parameter.`), 154 | makeTestError(`{local foo(a): 1, a: true}`, `test:1:14-15 Expected operator = but got ":"`), 155 | makeTestError(`{local foo(a) = (a b), a: true}`, `test:1:20-21 Expected token ")" but got (IDENTIFIER, "b")`), 156 | 157 | makeTestError(`{assert (a b), a: true}`, `test:1:12-13 Expected token ")" but got (IDENTIFIER, "b")`), 158 | makeTestError(`{assert a: (a b), a: true}`, `test:1:15-16 Expected token ")" but got (IDENTIFIER, "b")`), 159 | 160 | makeTestError(`{function(a, b) a+b: true}`, `test:1:2-10 Unexpected: (function, "function") while parsing field definition`), 161 | 162 | makeTestError(`[(a b), 2, 3]`, `test:1:5-6 Expected token ")" but got (IDENTIFIER, "b")`), 163 | makeTestError(`[1, (a b), 2, 3]`, `test:1:8-9 Expected token ")" but got (IDENTIFIER, "b")`), 164 | makeTestError(`[a for b in [1 2 3]]`, `test:1:16-17 Expected a comma before next array element.`), 165 | 166 | makeTestError(`for`, `test:1:1-4 Unexpected: (for, "for") while parsing terminal`), 167 | makeTestError(``, `test:1:1 Unexpected end of file.`), 168 | makeTestError(`((a b))`, `test:1:5-6 Expected token ")" but got (IDENTIFIER, "b")`), 169 | makeTestError(`a.1`, `test:1:3-4 Expected token IDENTIFIER but got (NUMBER, "1")`), 170 | makeTestError(`super.1`, `test:1:7-8 Expected token IDENTIFIER but got (NUMBER, "1")`), 171 | makeTestError(`super[(a b)]`, `test:1:10-11 Expected token ")" but got (IDENTIFIER, "b")`), 172 | makeTestError(`super[a b]`, `test:1:9-10 Expected token "]" but got (IDENTIFIER, "b")`), 173 | makeTestError(`super`, `test:1:1-6 Expected . or [ after super.`), 174 | 175 | makeTestError(`assert (a b); true`, `test:1:11-12 Expected token ")" but got (IDENTIFIER, "b")`), 176 | makeTestError(`assert a: (a b); true`, `test:1:14-15 Expected token ")" but got (IDENTIFIER, "b")`), 177 | makeTestError(`assert a: 'foo', true`, `test:1:16-17 Expected token ";" but got (",", ",")`), 178 | makeTestError(`assert a: 'foo'; (a b)`, `test:1:21-22 Expected token ")" but got (IDENTIFIER, "b")`), 179 | 180 | makeTestError(`error (a b)`, `test:1:10-11 Expected token ")" but got (IDENTIFIER, "b")`), 181 | 182 | makeTestError(`if (a b) then c`, `test:1:7-8 Expected token ")" but got (IDENTIFIER, "b")`), 183 | makeTestError(`if a b c`, `test:1:6-7 Expected token then but got (IDENTIFIER, "b")`), 184 | makeTestError(`if a then (b c)`, `test:1:14-15 Expected token ")" but got (IDENTIFIER, "c")`), 185 | makeTestError(`if a then b else (c d)`, `test:1:21-22 Expected token ")" but got (IDENTIFIER, "d")`), 186 | 187 | makeTestError(`function(a) (a b)`, `test:1:16-17 Expected token ")" but got (IDENTIFIER, "b")`), 188 | makeTestError(`function a a`, `test:1:10-11 Expected ( but got (IDENTIFIER, "a")`), 189 | 190 | makeTestError(`import (a b)`, `test:1:11-12 Expected token ")" but got (IDENTIFIER, "b")`), 191 | makeTestError(`import (a+b)`, `test:1:9-12 Computed imports are not allowed`), 192 | makeTestError(`importstr (a b)`, `test:1:14-15 Expected token ")" but got (IDENTIFIER, "b")`), 193 | makeTestError(`importstr (a+b)`, `test:1:12-15 Computed imports are not allowed`), 194 | 195 | makeTestError(`local a = b ()`, `test:1:15 Expected , or ; but got end of file`), 196 | makeTestError(`local a = b; (a b)`, `test:1:17-18 Expected token ")" but got (IDENTIFIER, "b")`), 197 | 198 | makeTestError(`1+ <<`, `test:1:4-6 Not a unary operator: <<`), 199 | makeTestError(`-(a b)`, `test:1:5-6 Expected token ")" but got (IDENTIFIER, "b")`), 200 | makeTestError(`1~2`, `test:1:2-3 Not a binary operator: ~`), 201 | 202 | makeTestError(`a[(b c)]`, `test:1:6-7 Expected token ")" but got (IDENTIFIER, "c")`), 203 | makeTestError(`a[b c]`, `test:1:5-6 Expected token "]" but got (IDENTIFIER, "c")`), 204 | 205 | makeTestError(`a{b c}`, `test:1:5-6 Expected token OPERATOR but got (IDENTIFIER, "c")`), 206 | ] 207 | 208 | describe("Parsing from text", () => { 209 | for (let s of errorTests) { 210 | it(`${JSON.stringify(s.input)}`, () => { 211 | const tokens = lexer.Lex("test", s.input); 212 | if (lexical.isStaticError(tokens)) { 213 | throw new Error( 214 | `Unexpected lex error\n input: ${s}\n error: ${tokens.Error()}`); 215 | } 216 | 217 | const parse = parser.Parse(tokens); 218 | if (!lexical.isStaticError(parse)) { 219 | throw new Error( 220 | `Expected parse error but got success\n input: ${s.input}`); 221 | } 222 | 223 | assert.equal(parse.Error(), s.err); 224 | }); 225 | } 226 | }); 227 | 228 | // func TestAST(t *testing.T) { 229 | // files, _ := ioutil.ReadDir("./test_data/ast") 230 | // for _, f := range files { 231 | // if strings.HasSuffix(f.Name(), ".ast") { 232 | // continue 233 | // } 234 | 235 | // assertParse( 236 | // t, 237 | // "./test_data/ast/"+f.Name(), 238 | // "./test_data/ast/"+f.Name()+".ast") 239 | // } 240 | // } 241 | 242 | // func assertParse(t *testing.T, sourceFile string, targetFile string) { 243 | // // Parse source document into a JSON string. 244 | // sourceBytes, err := ioutil.ReadFile(sourceFile) 245 | // if err != nil { 246 | // t.Errorf("Failed to read test file\n file name: %s\n error: %v", sourceFile, err) 247 | // } 248 | // source := string(sourceBytes) 249 | 250 | // sourceTokens, err := Lex("stdin", source) 251 | // if err != nil { 252 | // log.Fatalf("Unexpected lex error\n filename: %s\n input: %v\n error: %v", sourceFile, source, err) 253 | // } 254 | 255 | // node, err := Parse(sourceTokens) 256 | // if err != nil { 257 | // log.Fatalf("Unexpected parse error\n filename: %s\n input: %v\n error: %v", sourceFile, sourceTokens, err) 258 | // } 259 | 260 | // sourceAstBytes, err := json.MarshalIndent(node, "", " ") 261 | // if err != nil { 262 | // log.Fatalf("Unexpected serialization error\n input: %v\n error: %v", source, err) 263 | // } 264 | 265 | // sourceAstString := strings.TrimSpace(string(sourceAstBytes)) 266 | 267 | // // Get target source as a JSON string. 268 | // targetAstBytes, err := ioutil.ReadFile(targetFile) 269 | // if err != nil { 270 | // t.Errorf("Failed to read test file\n file name: %s\n error: %v", sourceFile, err) 271 | // } 272 | // targetAstString := strings.TrimSpace(string(targetAstBytes)) 273 | 274 | // // Compare. 275 | // if sourceAstString != targetAstString { 276 | // t.Errorf( 277 | // "Parsed AST does not match target AST\n filename: %s\n parsed output:\n%s", 278 | // sourceFile, 279 | // sourceAstString) 280 | // } 281 | // } 282 | -------------------------------------------------------------------------------- /test/server/ast/workspace_tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, assert } from 'chai'; 2 | import * as path from 'path'; 3 | import * as url from 'url'; 4 | 5 | import * as im from 'immutable'; 6 | 7 | import * as ast from '../../../compiler/lexical-analysis/ast'; 8 | import * as editor from '../../../compiler/editor'; 9 | import * as lexical from '../../../compiler/lexical-analysis/lexical'; 10 | 11 | const wd = path.resolve("."); 12 | 13 | class TestLibPathResolver extends editor.LibPathResolver { 14 | private fs = im.Set([ 15 | "/usr/share/file1.libsonnet", 16 | `${wd}/file2.libsonnet`, 17 | "/usr/share/file3.libsonnet", 18 | ]); 19 | 20 | constructor() { 21 | super(); 22 | 23 | this.libPaths = im.List([ 24 | "/usr/share", 25 | ]); 26 | } 27 | 28 | protected pathExists = (path: string): boolean => { 29 | return this.fs.contains(path); 30 | } 31 | }; 32 | 33 | class SuccessTest { 34 | constructor( 35 | public readonly title: string, 36 | public readonly inputPath: string | ast.Import, 37 | public readonly targetPath: string, 38 | ) {} 39 | } 40 | 41 | const dummyLoc = new lexical.Location(-1, -1); 42 | 43 | const successTests = im.List([ 44 | new SuccessTest( 45 | "Resolves simple absolute path", 46 | "file:///usr/share/file1.libsonnet", 47 | "file:///usr/share/file1.libsonnet"), 48 | new SuccessTest( 49 | "Resolves simple file imported from lib path", 50 | new ast.Import( 51 | "file1.libsonnet", 52 | lexical.MakeLocationRange("test.jsonnet", dummyLoc, dummyLoc)), 53 | "file:///usr/share/file1.libsonnet"), 54 | new SuccessTest( 55 | "Resolves simple file imported from current directory", 56 | new ast.Import( 57 | "file2.libsonnet", 58 | lexical.MakeLocationRange("test.jsonnet", dummyLoc, dummyLoc)), 59 | `file://${wd}/file2.libsonnet`), 60 | new SuccessTest( 61 | "Resolves simple file imported absolute path", 62 | new ast.Import( 63 | "/usr/share/file3.libsonnet", 64 | lexical.MakeLocationRange("test.jsonnet", dummyLoc, dummyLoc)), 65 | "file:///usr/share/file3.libsonnet"), 66 | ]); 67 | 68 | describe("Successfully search library paths for Jsonnet library", () => { 69 | const resolver = new TestLibPathResolver(); 70 | 71 | for (let test of successTests.toArray()) { 72 | it(`${test.title}`, () => { 73 | const actual = resolver.resolvePath(test.inputPath); 74 | assert.isNotNull(actual); 75 | assert.equal(actual.protocol, "file:"); 76 | assert.equal(actual.href, test.targetPath); 77 | }); 78 | } 79 | }); -------------------------------------------------------------------------------- /test/server/parser/completion_tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, assert } from 'chai'; 2 | 3 | import * as im from 'immutable'; 4 | 5 | import * as ast from '../../../compiler/lexical-analysis/ast'; 6 | import * as local from '../../../server/local'; 7 | import * as lexical from '../../../compiler/lexical-analysis/lexical'; 8 | import * as lexer from '../../../compiler/lexical-analysis/lexer'; 9 | import * as parser from '../../../compiler/lexical-analysis/parser'; 10 | import * as _static from '../../../compiler/static'; 11 | import * as testWorkspace from '../test_workspace'; 12 | 13 | class SuccessfulParseCompletionTest { 14 | private readonly completionSet: im.Set; 15 | constructor( 16 | public readonly name: string, 17 | public readonly source: string, 18 | public readonly loc: lexical.Location, 19 | readonly completions: string[], 20 | ) { 21 | this.completionSet = im.Set(completions); 22 | } 23 | 24 | public runTest = async () => { 25 | const documents = 26 | new testWorkspace.FsDocumentManager(new local.VsPathResolver()) 27 | const compiler = new local.VsCompilerService(); 28 | const analyzer = new _static.Analyzer(documents, compiler) 29 | 30 | const tokens = lexer.Lex("test name", this.source); 31 | if (lexical.isStaticError(tokens)) { 32 | throw new Error(`Failed to lex test source`); 33 | } 34 | 35 | const root = parser.Parse(tokens); 36 | if (lexical.isStaticError(root)) { 37 | throw new Error(`Failed to parse test source`); 38 | } 39 | 40 | const parse = new _static.ParsedDocument( 41 | this.source, tokens, root, 0); 42 | 43 | const cis = await analyzer.completionsFromParse("", parse, this.loc, false); 44 | const completionSet = cis.reduce( 45 | (acc, ci): im.Set => { 46 | return acc.add(ci.label); 47 | }, 48 | im.Set()); 49 | 50 | assert.isTrue(completionSet.equals(this.completionSet)); 51 | } 52 | } 53 | 54 | // 55 | // Autocomplete tests for successful parses. 56 | // 57 | 58 | const parsedCompletionTests = [ 59 | new SuccessfulParseCompletionTest( 60 | "Simple object completion", 61 | `local foo = "3"; {bar: f}`, 62 | new lexical.Location(1, 25), 63 | ["foo"]), 64 | new SuccessfulParseCompletionTest( 65 | "Simple local completion", 66 | `local foo = "3"; local bar = f; {}`, 67 | new lexical.Location(1, 31), 68 | ["foo", "bar"]), 69 | new SuccessfulParseCompletionTest( 70 | "Simple end-of-document completion", 71 | `local foo = "3"; f`, 72 | new lexical.Location(1, 19), 73 | ["foo"]), 74 | new SuccessfulParseCompletionTest( 75 | "Suggest nothing when identifier resolves", 76 | `local foo = "3"; local bar = 4; foo`, 77 | new lexical.Location(1, 36), 78 | []), 79 | new SuccessfulParseCompletionTest( 80 | "Suggest both variables in environment", 81 | `local foo = "3"; local bar = 4; f`, 82 | new lexical.Location(1, 36), 83 | ["foo", "bar"]), 84 | new SuccessfulParseCompletionTest( 85 | "Don't suggest from inside the `local` keyword", 86 | `local foo = "3"; {}`, 87 | new lexical.Location(1, 3), 88 | []), 89 | new SuccessfulParseCompletionTest( 90 | "Don't suggest from inside a string literal", 91 | `local foo = "3"; {bar: "f"}`, 92 | new lexical.Location(1, 26), 93 | []), 94 | new SuccessfulParseCompletionTest( 95 | "Don't suggest from inside a number literal", 96 | `local foo = "3"; {bar: 32}`, 97 | new lexical.Location(1, 25), 98 | []), 99 | new SuccessfulParseCompletionTest( 100 | "Don't suggest from inside a comment", 101 | `{} // This is a comment`, 102 | new lexical.Location(1, 14), 103 | []), 104 | new SuccessfulParseCompletionTest( 105 | "Suggest nothing if index ID resolves to object field", 106 | `local foo = {bar: "bar", baz: "baz"}; foo.bar`, 107 | new lexical.Location(1, 46), 108 | []), 109 | new SuccessfulParseCompletionTest( 110 | "Suggest only name of index ID if it resolves to object field", 111 | `local foo = {bar: "bar", baz: "baz"}; foo.ba`, 112 | new lexical.Location(1, 45), 113 | ["bar", "baz"]), 114 | new SuccessfulParseCompletionTest( 115 | "Suggest name if ID resolves to object", 116 | `local foo = {bar: "bar", baz: "baz"}; foo`, 117 | new lexical.Location(1, 42), 118 | []), 119 | new SuccessfulParseCompletionTest( 120 | "Don't suggest completions for members that aren't completable", 121 | `local foo = {bar: "bar", baz: "baz"}; foo.bar.b`, 122 | new lexical.Location(1, 48), 123 | []), 124 | new SuccessfulParseCompletionTest( 125 | "Don't suggest completions when target is index with invalid id", 126 | `local foo = {bar: "bar", baz: "baz"}; foo.bat.b`, 127 | new lexical.Location(1, 48), 128 | []), 129 | new SuccessfulParseCompletionTest( 130 | "Follow mixins for suggestions", 131 | `local foo = {bar: "bar"} + {baz: "baz"}; foo.ba`, 132 | new lexical.Location(1, 48), 133 | ["bar", "baz"]), 134 | 135 | // Tests that will work as `onComplete` becomes more feature-ful. 136 | // new CompletionTest( 137 | // "Don't get hung up when references form an infinite loop", 138 | // `local foo = foo; foo`, 139 | // new error.Location(1, 25), 140 | // []), 141 | // new CompletionTest( 142 | // "Simple suggestion through `self`", 143 | // `{bar: 42, foo: self.b}`, 144 | // new error.Location(1, 22), 145 | // ["bar", "foo"]), 146 | // new CompletionTest( 147 | // "Simple suggestion through `self`", 148 | // `{bar: 42} + {foo: super.b}`, 149 | // new error.Location(1, 22), 150 | // ["bar", "foo"]), 151 | // new SuccessfulParseCompletionTest( 152 | // "Failed to complete from field name", 153 | // `local foo = "3"; {f: 4}`, 154 | // new error.Location(1, 20), 155 | // []), 156 | 157 | // May or may not make sense. 158 | // new CompletionTest( 159 | // "Suggest only the name in an apply", 160 | // `local foo() = 3; local bar = 4; foo()`, 161 | // new error.Location(1, 21), 162 | // ["foo"]), 163 | // new SuccessfulParseCompletionTest( 164 | // "Don't suggest completions from within index", 165 | // `local foo = {bar: "bar", baz: "baz"}; foo.bar.b`, 166 | // new error.Location(1, 45), 167 | // []), 168 | ]; 169 | 170 | // 171 | // Setup. 172 | // 173 | 174 | const documents = 175 | new testWorkspace.FsDocumentManager(new local.VsPathResolver()); 176 | const compiler = new local.VsCompilerService(); 177 | const analyzer = new _static.Analyzer(documents, compiler); 178 | 179 | // 180 | // Tests 181 | // 182 | 183 | describe("Suggestions for successful parses", () => { 184 | parsedCompletionTests.forEach(range => { 185 | it(range.name, async () => { 186 | await range.runTest(); 187 | }); 188 | }) 189 | }); 190 | -------------------------------------------------------------------------------- /test/server/parser/resolve_tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, assert } from 'chai'; 2 | 3 | import * as ast from '../../../compiler/lexical-analysis/ast'; 4 | import * as local from '../../../server/local'; 5 | import * as lexical from '../../../compiler/lexical-analysis/lexical'; 6 | import * as lexer from '../../../compiler/lexical-analysis/lexer'; 7 | import * as parser from '../../../compiler/lexical-analysis/parser'; 8 | import * as _static from '../../../compiler/static'; 9 | import * as testWorkspace from '../test_workspace'; 10 | 11 | class LocatedSpec { 12 | constructor( 13 | public locatedCheck: ast.NodeKind | null = null, 14 | ) {} 15 | } 16 | 17 | class AnalyzableFailedLocatedSpec extends LocatedSpec { 18 | constructor( 19 | public locatedCheck: ast.NodeKind | null = null, 20 | ) { 21 | super(locatedCheck); 22 | } 23 | } 24 | 25 | class UnanalyzableFailedLocatedSpec extends LocatedSpec { 26 | constructor() { 27 | super(); 28 | } 29 | } 30 | 31 | const isFailedLocatedSpec = ( 32 | spec 33 | ): spec is AnalyzableFailedLocatedSpec | UnanalyzableFailedLocatedSpec => { 34 | return spec instanceof AnalyzableFailedLocatedSpec || 35 | spec instanceof UnanalyzableFailedLocatedSpec; 36 | } 37 | 38 | const isAnalyzableFailedLocatedSpec = ( 39 | spec 40 | ): spec is AnalyzableFailedLocatedSpec => { 41 | return spec instanceof AnalyzableFailedLocatedSpec; 42 | } 43 | 44 | const isUnAnalyzableFailedLocatedSpec = ( 45 | spec 46 | ): spec is UnanalyzableFailedLocatedSpec => { 47 | return spec instanceof UnanalyzableFailedLocatedSpec; 48 | } 49 | 50 | class ResolvedSpec extends LocatedSpec { 51 | constructor( 52 | public locatedCheck: ast.NodeKind | null = null, 53 | public resolvedTypeCheck: ast.NodeKind | null = null, 54 | public assertions: (node: TResolved) => void = (node) => {} 55 | ) { 56 | super(locatedCheck); 57 | } 58 | } 59 | 60 | const isResolvedSpec = (spec): spec is ResolvedSpec => { 61 | return spec instanceof ResolvedSpec; 62 | } 63 | 64 | class FailedResolvedSpec extends LocatedSpec { 65 | constructor( 66 | public assertions: (node: TResolveFailure) => void = (node) => {} 67 | ) { 68 | super(); 69 | } 70 | } 71 | 72 | const isFailedResolvedSpec = (spec): spec is FailedResolvedSpec => { 73 | return spec instanceof FailedResolvedSpec; 74 | } 75 | 76 | class RangeSpec { 77 | constructor( 78 | public name: string, 79 | public line: number, 80 | public beginCol: number, 81 | public endCol: number, 82 | public spec: LocatedSpec | ResolvedSpec, 83 | ) { 84 | if (beginCol > endCol) { 85 | throw new Error(`Invalid range spec: begin column '${beginCol}' can't be less than end column '${endCol}'`); 86 | } 87 | } 88 | 89 | public verifyRangeSpec = (root: ast.Node): void => { 90 | for (let col = this.beginCol; col < this.endCol; col++) { 91 | const coords = `(line: ${this.line}, col: ${col})`; 92 | 93 | const spec = this.spec; 94 | let found = _static.getNodeAtPositionFromAst( 95 | root, new lexical.Location(this.line, col)); 96 | 97 | if (isAnalyzableFailedLocatedSpec(spec)) { 98 | if (ast.isAnalyzableFindFailure(found)) { 99 | found = found.kind === "AfterLineEnd" 100 | ? found.terminalNodeOnCursorLine 101 | : found.tightestEnclosingNode; 102 | } else { 103 | throw new Error(`Expected analyzable failure to locate node ${coords}`); 104 | } 105 | } else if (isUnAnalyzableFailedLocatedSpec(spec)) { 106 | if (!ast.isUnanalyzableFindFailure(found)) { 107 | throw new Error(`Expected to unanalyzable failure to locate node ${coords}`); 108 | } 109 | } 110 | 111 | if (spec.locatedCheck != null) { 112 | if (ast.isFindFailure(found)) { 113 | throw new Error(`Expected to find a node ${coords}`); 114 | } 115 | assert.equal(found.type, spec.locatedCheck); 116 | } 117 | 118 | if (isResolvedSpec(spec)) { 119 | if (ast.isFindFailure(found)) { 120 | throw new Error(`Expected to resolve node, but could not locate ${coords}`); 121 | } 122 | if (!ast.isResolvable(found)) { 123 | throw new Error(`Expected to resolve node, but was not resolvable ${coords}`); 124 | } 125 | const resolved = found.resolve(ctx); 126 | if (ast.isResolveFailure(resolved)) { 127 | throw new Error(`Expected to resolve node, but failed ${coords}`); 128 | } 129 | 130 | if (spec.resolvedTypeCheck != null) { 131 | if (ast.isIndexedObjectFields(resolved.value)) { 132 | throw new Error(`Expected to resolve to node, but resolved object fields failed ${coords}`); 133 | } 134 | assert.equal(resolved.value.type, spec.resolvedTypeCheck); 135 | } 136 | spec.assertions(resolved.value) 137 | } else if (isFailedResolvedSpec(spec)) { 138 | if (ast.isFindFailure(found)) { 139 | throw new Error(`Expected to resolve node, but could not locate ${coords}`); 140 | } 141 | if (!ast.isResolvable(found)) { 142 | throw new Error(`Expected to find resolvable node, whose resolution fails, but node was not resolvable ${coords}`); 143 | } 144 | 145 | const resolved = found.resolve(ctx); 146 | if (!ast.isResolveFailure(resolved)) { 147 | throw new Error(`Expected to fail to resolve node, but resolve succeeded ${coords}`); 148 | } 149 | spec.assertions(resolved); 150 | } 151 | } 152 | } 153 | } 154 | 155 | const source = ` 156 | { 157 | local localVal1 = 3, 158 | field1: localVal1, 159 | local localVal2(param1) = param1, 160 | field2:: localVal2, 161 | local mixin1 = {foo: "bar"} + {bar: "baz"}, 162 | field3: mixin1, 163 | local mixin2 = mixin1 + {foo: "foobar"}, 164 | field4: mixin2, 165 | local object1 = {baz: "bat"}, 166 | local mixin3 = mixin2 + object1, 167 | field5: mixin3, 168 | local mixin4 = mixin3 + {foo+: "baz"}, 169 | field6: mixin4, 170 | }`; 171 | 172 | const ranges = [ 173 | new RangeSpec( 174 | "locate failed when cursor before any nodes", 175 | 1, 1, 2, new UnanalyzableFailedLocatedSpec()), 176 | new RangeSpec( 177 | "locate failed when cursor before any nodes, and after range of text line", 178 | 1, 1, 2, new UnanalyzableFailedLocatedSpec()), 179 | new RangeSpec( 180 | "located `local` node in object", 181 | 3, 3, 9, new AnalyzableFailedLocatedSpec("ObjectFieldNode")), 182 | new RangeSpec( 183 | "resolved `localVal1` to number 3", 184 | 4, 11, 21, 185 | new ResolvedSpec( 186 | "IdentifierNode", 187 | "LiteralNumberNode", 188 | (node: ast.LiteralNumber) => node.originalString === "3")), 189 | new RangeSpec( 190 | "failed to find `localVal1` for location after end of line", 191 | 4, 22, 23, new AnalyzableFailedLocatedSpec("IdentifierNode")), 192 | new RangeSpec( 193 | "failed to resolve value for field2; resolve to function `localVal2`", 194 | 6, 12, 21, 195 | new FailedResolvedSpec( 196 | (fn: ast.ResolvedFunction) => { 197 | ast.isObjectField(fn.functionNode) && 198 | fn.functionNode.id != null && 199 | fn.functionNode.id.name == "localVal2" 200 | })), 201 | new RangeSpec( 202 | "resolved `mixin1` to fields", 203 | 8, 11, 17, 204 | new ResolvedSpec( 205 | null, null, 206 | (fields: ast.IndexedObjectFields) => { 207 | const foo = fields.get("foo"); 208 | assert.isTrue(foo && foo.expr2 && ast.isLiteralString(foo.expr2) && 209 | foo.expr2.value === "bar"); 210 | const bar = fields.get("bar"); 211 | assert.isTrue(bar && bar.expr2 && ast.isLiteralString(bar.expr2) && 212 | bar.expr2.value === "baz"); 213 | })), 214 | new RangeSpec( 215 | "recursively resolve `mixin2` to fields", 216 | 10, 11, 17, 217 | new ResolvedSpec( 218 | null, null, 219 | (fields: ast.IndexedObjectFields) => { 220 | const foo = fields.get("foo"); 221 | assert.isTrue(foo && foo.expr2 && ast.isLiteralString(foo.expr2) && 222 | foo.expr2.value === "foobar"); 223 | 224 | const bar = fields.get("bar"); 225 | assert.isTrue(bar && bar.expr2 && ast.isLiteralString(bar.expr2) && 226 | bar.expr2.value === "baz"); 227 | })), 228 | new RangeSpec( 229 | "recursively resolve `mixin3` to fields", 230 | 13, 11, 17, 231 | new ResolvedSpec( 232 | null, null, 233 | (fields: ast.IndexedObjectFields) => { 234 | const foo = fields.get("foo"); 235 | assert.isTrue(foo && foo.expr2 && ast.isLiteralString(foo.expr2) && 236 | foo.expr2.value === "foobar"); 237 | 238 | const bar = fields.get("bar"); 239 | assert.isTrue(bar && bar.expr2 && ast.isLiteralString(bar.expr2) && 240 | bar.expr2.value === "baz"); 241 | 242 | const baz = fields.get("baz"); 243 | assert.isTrue(baz && baz.expr2 && ast.isLiteralString(baz.expr2) && 244 | baz.expr2.value === "bat"); 245 | })), 246 | 247 | // NOTE: This test will fail until we add support for super sugar in 248 | // our resolution code. See comment in `analyzer.ts`. 249 | // new RangeSpec( 250 | // "resolve `mixin3` to fields, with super sugar", 251 | // 15, 11, 17, 252 | // new ResolvedSpec( 253 | // null, null, 254 | // (fields: ast.IndexedObjectFields) => { 255 | // const foo = fields.get("foo"); 256 | // assert.isTrue(foo && foo.expr2 && ast.isLiteralString(foo.expr2) && 257 | // foo.expr2.value === "foobarbaz"); 258 | // })), 259 | ]; 260 | 261 | // 262 | // Setup. 263 | // 264 | 265 | const documents = 266 | new testWorkspace.FsDocumentManager(new local.VsPathResolver()); 267 | const compiler = new local.VsCompilerService(); 268 | const analyzer = new _static.Analyzer(documents, compiler); 269 | const ctx = new ast.ResolutionContext(compiler, documents, ""); 270 | 271 | const tokens = lexer.Lex("test string", source); 272 | if (lexical.isStaticError(tokens)) { 273 | throw new Error(`Failed to lex test source`); 274 | } 275 | 276 | const root = parser.Parse(tokens); 277 | if (lexical.isStaticError(root)) { 278 | throw new Error(`Failed to parse test source`); 279 | } 280 | 281 | // 282 | // Tests 283 | // 284 | 285 | describe("Finding nodes by position", () => { 286 | ranges.forEach(range => { 287 | it(range.name, () => { 288 | range.verifyRangeSpec(root); 289 | }); 290 | }) 291 | }); 292 | 293 | // 294 | // Utilities 295 | // 296 | 297 | const resolveAt = ( 298 | root: ast.Node, line: number, column: number 299 | ): ast.Node | null => { 300 | const loc = new lexical.Location(line, column); 301 | let node = _static.getNodeAtPositionFromAst(root, loc); 302 | if (ast.isAnalyzableFindFailure(node)) { 303 | node = node.tightestEnclosingNode; 304 | } else if (ast.isUnanalyzableFindFailure(node)) { 305 | return null; 306 | } 307 | 308 | if (ast.isResolvable(node)) { 309 | const resolved = node.resolve(ctx); 310 | if ( 311 | ast.isResolveFailure(resolved) || 312 | ast.isIndexedObjectFields(resolved.value) 313 | ) { 314 | return null; 315 | } 316 | return resolved.value; 317 | } 318 | return null; 319 | } 320 | 321 | const assertResolvesTo = ( 322 | line: number, column: number, isType: (node: ast.Node) => node is T, 323 | assertions: (node: T) => void 324 | ) => { 325 | const resolved = resolveAt(root, line, column); 326 | assert.isNotNull(resolved); 327 | assert.isTrue(isType(resolved)); 328 | assertions(resolved); 329 | } 330 | -------------------------------------------------------------------------------- /test/server/test_workspace.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as url from 'url'; 3 | 4 | import * as ast from '../../compiler/lexical-analysis/ast'; 5 | import * as workspace from '../../compiler/editor'; 6 | 7 | export class FsDocumentManager implements workspace.DocumentManager { 8 | constructor(private readonly libResolver: workspace.LibPathResolver) {} 9 | 10 | public get = ( 11 | fileSpec: workspace.FileUri | ast.Import | ast.ImportStr, 12 | ): {text: string, version?: number, resolvedPath: string} => { 13 | const fileUri = this.libResolver.resolvePath(fileSpec); 14 | if (fileUri == null || fileUri.path == null) { 15 | throw new Error(`INTERNAL ERROR: Failed to parse URI '${fileSpec}'`); 16 | } 17 | 18 | return { 19 | text: fs.readFileSync(fileUri.path).toString(), 20 | version: -1, 21 | resolvedPath: fileUri.path, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "noFallthroughCasesInSwitch": true, 5 | "alwaysStrict": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "pretty": true, 8 | "noImplicitThis": false, 9 | "noImplicitReturns": true, 10 | "module": "commonjs", 11 | "target": "es6", 12 | "outDir": "out", 13 | "lib": [ 14 | "es6" 15 | ], 16 | "sourceMap": true, 17 | "rootDir": "." 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "package.jsonnet", 22 | "package.libsonnet", 23 | ".vscode-test" 24 | ] 25 | } --------------------------------------------------------------------------------