├── .deployment ├── .github └── workflows │ └── main_pyright-playground.yml ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── LICENSE ├── README.md ├── applicationinsights.json ├── client ├── .gitignore ├── .prettierrc ├── AboutPanel.tsx ├── App.tsx ├── CheckmarkMenu.tsx ├── EndpointUtils.ts ├── HeaderPanel.tsx ├── HoverHook.tsx ├── IconButton.tsx ├── LocalStorageUtils.ts ├── Locales.ts ├── LspClient.ts ├── LspSession.ts ├── Menu.tsx ├── MonacoEditor.tsx ├── PlaygroundSettings.ts ├── ProblemsPanel.tsx ├── PushButton.tsx ├── PyrightConfigSettings.ts ├── RightPanel.tsx ├── SettingsCheckBox.tsx ├── SettingsPanel.tsx ├── SvgIcon.tsx ├── TextWithLink.tsx ├── UrlUtils.ts ├── app.json ├── assets │ ├── favicon.png │ ├── pyright.png │ └── pyright_bw.png ├── babel.config.js ├── package-lock.json ├── package.json ├── tsconfig.json └── web │ └── index.html ├── package.json └── server ├── .prettierrc ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── src ├── logging.ts ├── lspClient.ts ├── main.ts ├── routes.ts ├── service.ts ├── session.ts └── sessionManager.ts ├── tsconfig.json └── webpack.config.js /.deployment: -------------------------------------------------------------------------------- 1 | [config] 2 | SCM_DO_BUILD_DURING_DEPLOYMENT=true 3 | -------------------------------------------------------------------------------- /.github/workflows/main_pyright-playground.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | name: Build and deploy Node.js app to Azure Web App - pyright-playground 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js version 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: "20.x" 23 | 24 | - name: build server 25 | working-directory: ./server 26 | run: | 27 | npm install 28 | npm run build --if-present 29 | 30 | - name: build client 31 | working-directory: ./client 32 | run: | 33 | npm install 34 | npm run build:web --if-present 35 | 36 | - name: Upload artifact for deployment job 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: node-app 40 | path: | 41 | server/build 42 | server/dist 43 | server/index.js 44 | 45 | deploy: 46 | runs-on: ubuntu-latest 47 | needs: build 48 | environment: 49 | name: "Production" 50 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 51 | 52 | steps: 53 | - name: Download artifact from build job 54 | uses: actions/download-artifact@v4 55 | with: 56 | name: node-app 57 | 58 | - name: "Deploy to Azure Web App" 59 | id: deploy-to-webapp 60 | uses: azure/webapps-deploy@v3 61 | with: 62 | app-name: "pyright-playground" 63 | slot-name: "Production" 64 | publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_3DB26F89A94D4390A8C575D32E77F5C5 }} 65 | package: . 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .expo 3 | build 4 | server/.env 5 | server/dist 6 | server/pyright_local 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach by Process ID", 9 | "processId": "${command:PickProcess}", 10 | "request": "attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "type": "node" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Eric Traut 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pyright Playground 2 | 3 | [Pyright Playground](https://pyright-play.net) provides a web experience for running Pyright. 4 | 5 | [Pyright](https://github.com/Microsoft/pyright) is a static type checker for Python. 6 | 7 | ## Community 8 | 9 | Do you have questions about Pyright Playground? Post your questions in [the discussion section](https://github.com/erictraut/pyright-playground/discussions). 10 | 11 | To report a bug or request an enhancement for Pyright Playground, file a new issue in the [pyright-playground issue tracker](https://github.com/erictraut/pyright-playground/issues). 12 | 13 | To report a bug or request an enhancement for Pyright, file a new issue in the [pyright issue tracker](https://github.com/microsoft/pyright/issues). 14 | -------------------------------------------------------------------------------- /applicationinsights.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | 4 | # Expo 5 | .expo/ 6 | dist/ 7 | web-build/ 8 | 9 | # Local env files 10 | .env*.local 11 | 12 | # TypeScript 13 | *.tsbuildinfo 14 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "printWidth": 100, 6 | "endOfLine": "auto", 7 | "overrides": [ 8 | { 9 | "files": ["*.yml", "*.yaml"], 10 | "options": { 11 | "tabWidth": 2 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /client/AboutPanel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * An "about this app" panel. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { useState } from 'react'; 8 | import { StyleSheet, Text, View } from 'react-native'; 9 | import IconButton from './IconButton'; 10 | import TextWithLink from './TextWithLink'; 11 | 12 | export interface AboutPanelProps { 13 | code: string; 14 | getShareableUrl: () => string; 15 | } 16 | 17 | export function AboutPanel(props: AboutPanelProps) { 18 | return ( 19 | 20 | 21 | {'Using the Playground'} 22 | 23 | 24 | { 25 | 'Type or paste Python code into the text editor, and Pyright will report any errors it finds.' 26 | } 27 | 28 | 32 | {'Pyright Playground GitHub site'} 33 | 34 | 35 | 36 | 37 | {'Sharing a Code Sample'} 38 | 39 | 40 | {'Copy a link or markdown to the clipboard.'} 41 | 42 | { 46 | return props.getShareableUrl(); 47 | }} 48 | /> 49 | { 53 | return `Code sample in [pyright playground](${props.getShareableUrl()})\n`; 54 | }} 55 | /> 56 | { 60 | return ( 61 | `Code sample in [pyright playground](${props.getShareableUrl()})\n\n` + 62 | '```' + 63 | `python\n${props.code}\n` + 64 | '```\n' 65 | ); 66 | }} 67 | /> 68 | 69 | 70 | 71 | {'Examples'} 72 | 73 | 80 | {'Hello World'} 81 | 82 | 89 | {'Protocols'} 90 | 91 | 98 | {'ParamSpecs'} 99 | 100 | 107 | {'Generics Syntax (Python 3.12)'} 108 | 109 | 110 | 111 | 112 | {'Pyright'} 113 | 114 | 115 | {'Pyright is an open-source standards-based static type checker for Python.'} 116 | 117 | 121 | {'Pyright documentation'} 122 | 123 | 124 | {'Pyright GitHub site'} 125 | 126 | 127 | ); 128 | } 129 | 130 | interface CopyToClipboardButtonProps { 131 | label: string; 132 | title: string; 133 | getTextToCopy: () => string; 134 | } 135 | 136 | interface CopyToClipboardButtonState { 137 | isCopied: boolean; 138 | } 139 | 140 | function CopyToClipboardButton(props: CopyToClipboardButtonProps) { 141 | const [buttonState, setButtonState] = useState({ isCopied: false }); 142 | 143 | return ( 144 | 145 | { 150 | const textToCopy = props.getTextToCopy(); 151 | 152 | try { 153 | navigator.clipboard.writeText(textToCopy); 154 | 155 | setButtonState({ isCopied: true }); 156 | 157 | setTimeout(() => { 158 | setButtonState({ isCopied: false }); 159 | }, 1000); 160 | } catch { 161 | // Ignore the error. 162 | } 163 | }} 164 | color={buttonState.isCopied ? '#090' : '#666'} 165 | hoverColor={buttonState.isCopied ? '#090' : '#333'} 166 | backgroundStyle={styles.clipboardButtonBackground} 167 | hoverBackgroundStyle={styles.clipboardButtonBackgroundHover} 168 | /> 169 | 170 | {props.label} 171 | 172 | 173 | ); 174 | } 175 | 176 | const styles = StyleSheet.create({ 177 | container: { 178 | flex: 1, 179 | flexDirection: 'column', 180 | alignSelf: 'stretch', 181 | paddingVertical: 8, 182 | paddingHorizontal: 12, 183 | }, 184 | headerText: { 185 | fontSize: 14, 186 | color: '#666', 187 | marginBottom: 8, 188 | fontVariant: ['small-caps'], 189 | }, 190 | aboutTextLink: { 191 | marginLeft: 16, 192 | marginRight: 8, 193 | fontSize: 13, 194 | marginBottom: 8, 195 | }, 196 | aboutText: { 197 | marginLeft: 16, 198 | marginRight: 8, 199 | fontSize: 13, 200 | color: '#333', 201 | marginBottom: 8, 202 | }, 203 | divider: { 204 | height: 1, 205 | borderTopWidth: 1, 206 | borderColor: '#eee', 207 | borderStyle: 'solid', 208 | marginVertical: 8, 209 | }, 210 | clipboardContainer: { 211 | flexDirection: 'row', 212 | alignItems: 'center', 213 | marginHorizontal: 20, 214 | marginVertical: 4, 215 | }, 216 | clipboardButtonBackground: { 217 | height: 26, 218 | width: 26, 219 | paddingVertical: 4, 220 | paddingHorizontal: 4, 221 | backgroundColor: '#fff', 222 | borderWidth: 1, 223 | borderRadius: 4, 224 | borderStyle: 'solid', 225 | borderColor: '#999', 226 | }, 227 | clipboardButtonBackgroundHover: { 228 | borderColor: '#666', 229 | }, 230 | clipboardButtonText: { 231 | marginLeft: 8, 232 | fontSize: 13, 233 | color: '#333', 234 | marginBottom: 2, 235 | }, 236 | }); 237 | -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Main UI for Pyright Playground web app. 4 | */ 5 | 6 | import { useEffect, useRef, useState } from 'react'; 7 | import { StyleSheet, View } from 'react-native'; 8 | import { MenuProvider } from 'react-native-popup-menu'; 9 | import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; 10 | import { HeaderPanel } from './HeaderPanel'; 11 | import { getInitialStateFromLocalStorage, setStateToLocalStorage } from './LocalStorageUtils'; 12 | import { LspClient } from './LspClient'; 13 | import { LspSession } from './LspSession'; 14 | import { MonacoEditor } from './MonacoEditor'; 15 | import { PlaygroundSettings } from './PlaygroundSettings'; 16 | import { ProblemsPanel } from './ProblemsPanel'; 17 | import { RightPanel, RightPanelType } from './RightPanel'; 18 | import { getStateFromUrl, updateUrlFromState } from './UrlUtils'; 19 | 20 | const lspClient = new LspClient(); 21 | 22 | export interface AppState { 23 | gotInitialState: boolean; 24 | code: string; 25 | diagnostics: Diagnostic[]; 26 | 27 | settings: PlaygroundSettings; 28 | requestedPyrightVersion: boolean; 29 | latestPyrightVersion?: string; 30 | supportedPyrightVersions?: string[]; 31 | 32 | isRightPanelDisplayed: boolean; 33 | rightPanelType: RightPanelType; 34 | 35 | isProblemsPanelDisplayed: boolean; 36 | isWaitingForResponse: boolean; 37 | } 38 | 39 | const initialState = getStateFromUrl() ?? getInitialStateFromLocalStorage(); 40 | 41 | export default function App() { 42 | const editorRef = useRef(null); 43 | const [appState, setAppState] = useState({ 44 | gotInitialState: false, 45 | code: '', 46 | settings: { 47 | configOverrides: {}, 48 | }, 49 | requestedPyrightVersion: false, 50 | diagnostics: [], 51 | isRightPanelDisplayed: true, 52 | rightPanelType: RightPanelType.About, 53 | isProblemsPanelDisplayed: initialState.code !== '', 54 | isWaitingForResponse: false, 55 | }); 56 | 57 | useEffect(() => { 58 | if (!appState.gotInitialState) { 59 | if (initialState.code !== '') { 60 | lspClient.updateCode(initialState.code); 61 | } 62 | 63 | setAppState((prevState) => { 64 | return { 65 | ...prevState, 66 | gotInitialState: true, 67 | code: initialState.code, 68 | settings: initialState.settings, 69 | isProblemsPanelDisplayed: 70 | prevState.isProblemsPanelDisplayed || initialState.code !== '', 71 | }; 72 | }); 73 | } 74 | }, [appState.gotInitialState]); 75 | 76 | // Request general status, including supported versions of pyright 77 | // from the service. 78 | useEffect(() => { 79 | if (!appState.requestedPyrightVersion) { 80 | setAppState((prevState) => { 81 | return { 82 | ...prevState, 83 | requestedPyrightVersion: true, 84 | }; 85 | }); 86 | 87 | LspSession.getPyrightServiceStatus() 88 | .then((status) => { 89 | const pyrightVersions = status.pyrightVersions; 90 | 91 | setAppState((prevState) => { 92 | return { 93 | ...prevState, 94 | latestPyrightVersion: 95 | pyrightVersions.length > 0 ? pyrightVersions[0] : undefined, 96 | supportedPyrightVersions: pyrightVersions, 97 | }; 98 | }); 99 | }) 100 | .catch((err) => { 101 | // Ignore errors here. 102 | }); 103 | } 104 | }); 105 | 106 | const handleKeyPress = (event: KeyboardEvent) => { 107 | // Swallow command-s or ctrl-s to prevent browser save. 108 | if (event.key === 's' && (event.ctrlKey || event.metaKey)) { 109 | event.preventDefault(); 110 | event.stopPropagation(); 111 | } 112 | }; 113 | 114 | useEffect(() => { 115 | window.addEventListener('keydown', handleKeyPress); 116 | 117 | return () => window.removeEventListener('keydown', handleKeyPress); 118 | }, []); 119 | 120 | useEffect(() => { 121 | setStateToLocalStorage({ code: appState.code, settings: appState.settings }); 122 | updateUrlFromState(appState); 123 | }, [appState.code, appState.settings]); 124 | 125 | useEffect(() => { 126 | lspClient.updateSettings(appState.settings); 127 | }, [appState.settings]); 128 | 129 | lspClient.requestNotification({ 130 | onDiagnostics: (diagnostics: Diagnostic[]) => { 131 | setAppState((prevState) => { 132 | return { 133 | ...prevState, 134 | diagnostics, 135 | }; 136 | }); 137 | }, 138 | onError: (message: string) => { 139 | setAppState((prevState) => { 140 | return { 141 | ...prevState, 142 | diagnostics: [ 143 | { 144 | message: `An error occurred when attempting to contact the pyright web service\n ${message}`, 145 | severity: DiagnosticSeverity.Error, 146 | range: { 147 | start: { line: 0, character: 0 }, 148 | end: { line: 0, character: 0 }, 149 | }, 150 | }, 151 | ], 152 | }; 153 | }); 154 | }, 155 | onWaitingForDiagnostics: (isWaiting) => { 156 | setAppState((prevState) => { 157 | return { 158 | ...prevState, 159 | isWaitingForResponse: isWaiting, 160 | }; 161 | }); 162 | }, 163 | }); 164 | 165 | function onShowRightPanel(rightPanelType?: RightPanelType) { 166 | setAppState((prevState) => { 167 | return { 168 | ...prevState, 169 | rightPanelType: rightPanelType ?? prevState.rightPanelType, 170 | isRightPanelDisplayed: rightPanelType !== undefined, 171 | }; 172 | }); 173 | } 174 | 175 | return ( 176 | 177 | 178 | 183 | 184 | { 190 | // Tell the LSP client about the code change. 191 | lspClient.updateCode(code); 192 | 193 | setAppState((prevState) => { 194 | return { ...prevState, code, isProblemsPanelDisplayed: true }; 195 | }); 196 | }} 197 | /> 198 | { 206 | setAppState((prevState) => { 207 | return { ...prevState, settings }; 208 | }); 209 | }} 210 | code={appState.code} 211 | getShareableUrl={() => { 212 | return updateUrlFromState(appState); 213 | }} 214 | /> 215 | 216 | { 219 | if (editorRef.current) { 220 | editorRef.current.selectRange(range); 221 | } 222 | }} 223 | expandProblems={appState.isProblemsPanelDisplayed} 224 | displayActivityIndicator={appState.isWaitingForResponse} 225 | /> 226 | 227 | 228 | ); 229 | } 230 | 231 | const styles = StyleSheet.create({ 232 | container: { 233 | flex: 1, 234 | flexDirection: 'column', 235 | overflow: 'hidden', 236 | }, 237 | middlePanelContainer: { 238 | flex: 1, 239 | flexDirection: 'row', 240 | }, 241 | }); 242 | -------------------------------------------------------------------------------- /client/CheckmarkMenu.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * A menu that displays a checkmark next to items. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { ScrollView, StyleSheet, TextInput, View } from 'react-native'; 8 | import { MenuItem } from './Menu'; 9 | import { createRef, useEffect, useState } from 'react'; 10 | 11 | export interface CheckmarkMenuProps { 12 | items: CheckmarkMenuItem[]; 13 | onSelect: (item: CheckmarkMenuItem, index: number) => void; 14 | includeSearchBox?: boolean; 15 | fixedSize?: { width: number; height: number }; 16 | onDismiss?: () => void; 17 | } 18 | 19 | export interface CheckmarkMenuItem { 20 | label: string; 21 | checked: boolean; 22 | title?: string; 23 | disabled?: boolean; 24 | } 25 | 26 | interface CheckmarkMenuState { 27 | searchFilter: string; 28 | } 29 | 30 | export function CheckmarkMenu(props: CheckmarkMenuProps) { 31 | const [state, setState] = useState({ 32 | searchFilter: '', 33 | }); 34 | const textInputRef = createRef(); 35 | 36 | const searchFilter = state.searchFilter.toLowerCase().trim(); 37 | const filteredItems = props.items.filter((item) => { 38 | return !searchFilter || item.label.toLowerCase().includes(searchFilter); 39 | }); 40 | 41 | // We need to defer the focus until after the menu has been 42 | // rendered, measured, and placed in its final position. 43 | useEffect(() => { 44 | if (textInputRef.current) { 45 | setTimeout(() => { 46 | if (textInputRef.current) { 47 | textInputRef.current.focus(); 48 | } 49 | }, 100); 50 | } 51 | }, [textInputRef]); 52 | 53 | return ( 54 | 55 | {props.includeSearchBox ? ( 56 | 57 | { 64 | setState((prevState) => { 65 | return { ...prevState, searchFilter: newValue }; 66 | }); 67 | }} 68 | onKeyPress={(event) => { 69 | if (event.nativeEvent.key === 'Escape') { 70 | if (props.onDismiss) { 71 | props.onDismiss(); 72 | } 73 | } 74 | }} 75 | spellCheck={false} 76 | /> 77 | 78 | ) : undefined} 79 | 80 | 83 | {filteredItems.map((item, index) => { 84 | return ( 85 | props.onSelect(item, index)} 91 | title={item.title} 92 | disabled={item.disabled} 93 | /> 94 | ); 95 | })} 96 | 97 | 98 | ); 99 | } 100 | 101 | const styles = StyleSheet.create({ 102 | container: { 103 | flexDirection: 'column', 104 | minWidth: 100, 105 | maxHeight: 300, 106 | }, 107 | contentContainer: {}, 108 | searchBoxContainer: { 109 | paddingHorizontal: 4, 110 | paddingTop: 4, 111 | paddingBottom: 8, 112 | borderBottomWidth: 1, 113 | borderBottomColor: '#ccc', 114 | borderStyle: 'solid', 115 | }, 116 | searchBox: { 117 | fontSize: 13, 118 | padding: 4, 119 | borderWidth: 1, 120 | borderColor: '#ccc', 121 | borderStyle: 'solid', 122 | backgroundColor: '#fff', 123 | }, 124 | }); 125 | -------------------------------------------------------------------------------- /client/EndpointUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Utility functions used for accessing network endpoints. 4 | */ 5 | 6 | export interface HeaderValues { 7 | token?: string; 8 | etag?: string; 9 | } 10 | 11 | export async function endpointGet( 12 | endpoint: string, 13 | headerValues: HeaderValues, 14 | body?: string | FormData, 15 | contentType = 'application/json' 16 | ) { 17 | return fetch(endpoint, { 18 | method: 'GET', 19 | headers: makeHeaders(headerValues, contentType), 20 | body, 21 | }); 22 | } 23 | 24 | export async function endpointPost( 25 | endpoint: string, 26 | headerValues: HeaderValues, 27 | body?: string | FormData, 28 | contentType = 'application/json' 29 | ) { 30 | return fetch(endpoint, { 31 | method: 'POST', 32 | headers: makeHeaders(headerValues, contentType), 33 | body, 34 | }); 35 | } 36 | 37 | export async function endpointPut( 38 | endpoint: string, 39 | headerValues: HeaderValues, 40 | body?: string | FormData, 41 | contentType = 'application/json' 42 | ) { 43 | return fetch(endpoint, { 44 | method: 'PUT', 45 | headers: makeHeaders(headerValues, contentType), 46 | body, 47 | }); 48 | } 49 | 50 | export async function endpointDelete( 51 | endpoint: string, 52 | headerValues: HeaderValues, 53 | body?: string | FormData | Blob, 54 | contentType = 'application/json' 55 | ) { 56 | return fetch(endpoint, { 57 | method: 'DELETE', 58 | headers: makeHeaders(headerValues, contentType), 59 | body, 60 | }); 61 | } 62 | 63 | function makeHeaders(headerValues: HeaderValues, contentType = 'application/json') { 64 | const headers: any = {}; 65 | if (contentType) { 66 | headers['Content-Type'] = contentType; 67 | } 68 | 69 | return headers; 70 | } 71 | -------------------------------------------------------------------------------- /client/HeaderPanel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Header bar with embedded controls for the playground. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native'; 8 | import { useAssets } from 'expo-asset'; 9 | import IconButton from './IconButton'; 10 | import { RightPanelType } from './RightPanel'; 11 | 12 | const headerIconButtonSize = 20; 13 | 14 | export interface HeaderPanelProps { 15 | isRightPanelDisplayed: boolean; 16 | rightPanelType: RightPanelType; 17 | onShowRightPanel: (rightPanelType?: RightPanelType) => void; 18 | } 19 | 20 | export function HeaderPanel(props: HeaderPanelProps) { 21 | const [assets, error] = useAssets([require('./assets/pyright_bw.png')]); 22 | 23 | let image = null; 24 | if (!error && assets) { 25 | image = ; 26 | } else { 27 | image = ; 28 | } 29 | 30 | return ( 31 | 32 | { 34 | Linking.openURL('https://github.com/microsoft/pyright'); 35 | }} 36 | > 37 | {image} 38 | 39 | 40 | Pyright Playground 41 | 42 | 43 | { 55 | props.onShowRightPanel(RightPanelType.Settings); 56 | }} 57 | /> 58 | { 69 | props.onShowRightPanel(RightPanelType.About); 70 | }} 71 | /> 72 | 73 | 74 | ); 75 | } 76 | 77 | const styles = StyleSheet.create({ 78 | container: { 79 | flex: -1, 80 | flexDirection: 'row', 81 | paddingHorizontal: 8, 82 | paddingBottom: 2, 83 | alignSelf: 'stretch', 84 | alignItems: 'center', 85 | backgroundColor: '#336', 86 | height: 42, 87 | }, 88 | pyrightIcon: { 89 | height: 24, 90 | width: 24, 91 | marginRight: 8, 92 | }, 93 | titleText: { 94 | color: '#fff', 95 | fontSize: 18, 96 | fontWeight: 'bold', 97 | fontVariant: ['small-caps'], 98 | }, 99 | controlsPanel: { 100 | flex: 1, 101 | flexDirection: 'row', 102 | justifyContent: 'flex-end', 103 | }, 104 | }); 105 | -------------------------------------------------------------------------------- /client/HoverHook.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * React hook that manages hover state. 4 | */ 5 | 6 | import { MutableRefObject, useEffect, useRef, useState } from 'react'; 7 | 8 | export function useHover(): [MutableRefObject, boolean] { 9 | const [value, setValue] = useState(false); 10 | const ref = useRef(null); 11 | const handleMouseOver = () => setValue(true); 12 | const handleMouseOut = () => setValue(false); 13 | 14 | useEffect(() => { 15 | const node = ref.current as any; 16 | if (node) { 17 | node.addEventListener('mouseover', handleMouseOver); 18 | node.addEventListener('mouseout', handleMouseOut); 19 | 20 | return () => { 21 | node.removeEventListener('mouseover', handleMouseOver); 22 | node.removeEventListener('mouseout', handleMouseOut); 23 | }; 24 | } 25 | }, [ref.current]); 26 | 27 | return [ref, value]; 28 | } 29 | -------------------------------------------------------------------------------- /client/IconButton.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * A button that displays an icon and handles press and hover events. 4 | */ 5 | 6 | import { IconDefinition } from '@ant-design/icons-svg/lib/types'; 7 | import { GestureResponderEvent, Pressable, StyleProp, StyleSheet, ViewStyle } from 'react-native'; 8 | import { useHover } from './HoverHook'; 9 | import { SvgIcon } from './SvgIcon'; 10 | 11 | interface IconButtonProps { 12 | iconDefinition: IconDefinition; 13 | iconSize: number; 14 | disabled?: boolean; 15 | title?: string; 16 | color?: string; 17 | hoverColor?: string; 18 | disableColor?: string; 19 | backgroundStyle?: StyleProp; 20 | hoverBackgroundStyle?: StyleProp; 21 | onPress: (event: GestureResponderEvent) => void; 22 | } 23 | 24 | export default function IconButton(props: IconButtonProps) { 25 | const [hoverRef, isHovered] = useHover(); 26 | 27 | let effectiveColor: string | undefined; 28 | if (props.disabled) { 29 | effectiveColor = props.disableColor ?? '#ccc'; 30 | } else if (isHovered) { 31 | effectiveColor = props.hoverColor ?? props.color; 32 | } else { 33 | effectiveColor = props.color; 34 | } 35 | 36 | return ( 37 |
38 | 49 | 54 | 55 |
56 | ); 57 | } 58 | 59 | const styles = StyleSheet.create({ 60 | defaultBackgroundStyle: { 61 | paddingHorizontal: 6, 62 | paddingVertical: 2, 63 | }, 64 | disabled: { 65 | opacity: 1, 66 | cursor: 'default', 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /client/LocalStorageUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Utility functions for working with local storage. 4 | */ 5 | 6 | import { PlaygroundState } from './PlaygroundSettings'; 7 | 8 | const localStorageKeyName = 'playgroundState'; 9 | 10 | export function getInitialStateFromLocalStorage(): PlaygroundState { 11 | const initialStateJson = getLocalStorageItem(localStorageKeyName); 12 | 13 | if (initialStateJson) { 14 | try { 15 | const result = JSON.parse(initialStateJson); 16 | if (result.code !== undefined && result.settings !== undefined) { 17 | return result; 18 | } 19 | } catch { 20 | // Fall through. 21 | } 22 | } 23 | 24 | return { code: '', settings: { configOverrides: {} } }; 25 | } 26 | 27 | export function setStateToLocalStorage(state: PlaygroundState) { 28 | setLocalStorageItem(localStorageKeyName, JSON.stringify(state)); 29 | } 30 | 31 | function getLocalStorageItem(key: string): string | undefined { 32 | try { 33 | return localStorage.getItem(key) ?? undefined; 34 | } catch { 35 | return undefined; 36 | } 37 | } 38 | 39 | function setLocalStorageItem(key: string, value: string | undefined) { 40 | try { 41 | if (value === undefined) { 42 | localStorage.removeItem(key); 43 | } else { 44 | localStorage.setItem(key, value); 45 | } 46 | } catch {} 47 | } 48 | -------------------------------------------------------------------------------- /client/Locales.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * A list of supported locales (localized languages) supported by pyright. 4 | */ 5 | 6 | export interface LocalInfo { 7 | displayName: string; 8 | code: string; 9 | } 10 | 11 | export const supportedLocales: LocalInfo[] = [ 12 | { 13 | displayName: 'Browser Default', 14 | code: '', 15 | }, 16 | { 17 | displayName: 'Chinese (Simplified) - 中文(简体)', 18 | code: 'zh-cn', 19 | }, 20 | { 21 | displayName: 'Chinese (Traditional) - 中文(繁體)', 22 | code: 'zh-tw', 23 | }, 24 | { 25 | displayName: 'Czech - čeština', 26 | code: 'cs', 27 | }, 28 | { 29 | displayName: 'English', 30 | code: 'en', 31 | }, 32 | { 33 | displayName: 'English (US)', 34 | code: 'en-us', 35 | }, 36 | { 37 | displayName: 'French - français', 38 | code: 'fr', 39 | }, 40 | { 41 | displayName: 'German - Deutsch', 42 | code: 'de', 43 | }, 44 | { 45 | displayName: 'Italian - italiano', 46 | code: 'it', 47 | }, 48 | { 49 | displayName: 'Japanese - 日本語', 50 | code: 'ja', 51 | }, 52 | { 53 | displayName: 'Korean - 한국어', 54 | code: 'ko', 55 | }, 56 | { 57 | displayName: 'Polish - polski', 58 | code: 'pl', 59 | }, 60 | { 61 | displayName: 'Portuguese (Brazil) - português', 62 | code: 'pt-br', 63 | }, 64 | { 65 | displayName: 'Russian - русский', 66 | code: 'ru', 67 | }, 68 | { 69 | displayName: 'Spanish - español', 70 | code: 'es', 71 | }, 72 | { 73 | displayName: 'Turkish - Türkçe', 74 | code: 'tr', 75 | }, 76 | ]; 77 | 78 | export function getLocaleDisplayName(code: string | undefined) { 79 | return supportedLocales.find((local) => local.code === code)?.displayName ?? ''; 80 | } 81 | -------------------------------------------------------------------------------- /client/LspClient.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Language server client that tracks local changes to the code and 4 | * talks to the remote language server via an LSP session. 5 | */ 6 | 7 | import { 8 | CompletionItem, 9 | CompletionList, 10 | Diagnostic, 11 | Position, 12 | SignatureHelp, 13 | WorkspaceEdit, 14 | } from 'vscode-languageserver-types'; 15 | import { HoverInfo, LspSession } from './LspSession'; 16 | import { PlaygroundSettings } from './PlaygroundSettings'; 17 | 18 | // Wait for a small amount before sending the request to the server. This allows 19 | // the user to type multiple characters before we send the request. 20 | const diagnosticDelayInMs = 500; 21 | 22 | export interface LspClientNotifications { 23 | onWaitingForDiagnostics?: (isWaiting: boolean) => void; 24 | onDiagnostics?: (diag: Diagnostic[]) => void; 25 | onError?: (message: string) => void; 26 | } 27 | 28 | export class LspClient { 29 | private _lspSession = new LspSession(); 30 | private _docContent = ''; 31 | private _docVersion = 0; 32 | private _diagnosticsTimer: any; 33 | private _notifications: LspClientNotifications; 34 | 35 | requestNotification(notifications: LspClientNotifications) { 36 | this._notifications = notifications; 37 | } 38 | 39 | // Updates the code and queues the change to be sent to the language server. 40 | updateCode(content: string) { 41 | if (content !== this._docContent) { 42 | this._docContent = content; 43 | this._docVersion++; 44 | this._lspSession.updateInitialCode(content); 45 | this._restartDiagnosticsTimer(); 46 | } 47 | } 48 | 49 | updateSettings(settings: PlaygroundSettings) { 50 | this._lspSession.updateSettings(settings); 51 | this._restartDiagnosticsTimer(); 52 | } 53 | 54 | async getHoverForPosition(code: string, position: Position): Promise { 55 | return this._lspSession.getHoverForPosition(code, position); 56 | } 57 | 58 | async getRenameEditsForPosition( 59 | code: string, 60 | position: Position, 61 | newName: string 62 | ): Promise { 63 | return this._lspSession.getRenameEditsForPosition(code, position, newName); 64 | } 65 | 66 | async getSignatureHelpForPosition( 67 | code: string, 68 | position: Position 69 | ): Promise { 70 | return this._lspSession.getSignatureHelpForPosition(code, position); 71 | } 72 | 73 | async getCompletionForPosition( 74 | code: string, 75 | position: Position 76 | ): Promise { 77 | return this._lspSession.getCompletionForPosition(code, position); 78 | } 79 | 80 | async resolveCompletionItem( 81 | completionItem: CompletionItem 82 | ): Promise { 83 | return this._lspSession.resolveCompletionItem(completionItem); 84 | } 85 | 86 | private _restartDiagnosticsTimer() { 87 | if (this._diagnosticsTimer) { 88 | clearTimeout(this._diagnosticsTimer); 89 | this._diagnosticsTimer = undefined; 90 | } 91 | 92 | this._diagnosticsTimer = setTimeout(() => { 93 | this._diagnosticsTimer = undefined; 94 | this._requestDiagnostics(); 95 | }, diagnosticDelayInMs); 96 | } 97 | 98 | private _requestDiagnostics() { 99 | let docVersion = this._docVersion; 100 | 101 | if (this._notifications.onWaitingForDiagnostics) { 102 | this._notifications.onWaitingForDiagnostics(true); 103 | } 104 | 105 | this._lspSession 106 | .getDiagnostics(this._docContent) 107 | .then((diagnostics) => { 108 | if (this._docVersion === docVersion) { 109 | if (this._notifications.onWaitingForDiagnostics) { 110 | this._notifications.onWaitingForDiagnostics(false); 111 | } 112 | 113 | if (this._notifications.onDiagnostics) { 114 | this._notifications.onDiagnostics(diagnostics); 115 | } 116 | } 117 | }) 118 | .catch((err) => { 119 | if (this._notifications.onWaitingForDiagnostics) { 120 | this._notifications.onWaitingForDiagnostics(false); 121 | } 122 | 123 | if (this._notifications.onError) { 124 | this._notifications.onError(err.message); 125 | } 126 | }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /client/LspSession.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Handles the state associated with a remote language server session. 4 | */ 5 | 6 | import { 7 | CompletionItem, 8 | CompletionList, 9 | Diagnostic, 10 | Position, 11 | Range, 12 | SignatureHelp, 13 | WorkspaceEdit, 14 | } from 'vscode-languageserver-types'; 15 | import { endpointDelete, endpointGet, endpointPost } from './EndpointUtils'; 16 | import { PlaygroundSettings } from './PlaygroundSettings'; 17 | 18 | export interface HoverInfo { 19 | contents: { 20 | kind: string; 21 | value: string; 22 | }; 23 | range: Range; 24 | } 25 | 26 | export interface ServerStatus { 27 | pyrightVersions: string[]; 28 | } 29 | 30 | // Number of attempts to create a new session before giving up. 31 | const maxErrorCount = 4; 32 | 33 | let appServerApiAddressPrefix = 'https://pyright-playground.azurewebsites.net/api/'; 34 | 35 | // TODO - this is for local debugging in the browser. Remove for 36 | // React Native code. 37 | const currentUrl = new URL(window.location.href); 38 | if (currentUrl.hostname === 'localhost') { 39 | appServerApiAddressPrefix = 'http://localhost:3000/api/'; 40 | } 41 | 42 | export class LspSession { 43 | private _sessionId: string | undefined; 44 | private _settings: PlaygroundSettings | undefined; 45 | private _initialCode = ''; 46 | 47 | updateSettings(settings: PlaygroundSettings) { 48 | this._settings = settings; 49 | 50 | // Force the current session to close so we can 51 | // create a new one with the updated settings. 52 | this._closeSession(); 53 | } 54 | 55 | updateInitialCode(text: string) { 56 | // When creating a new session, we can send the initial 57 | // code to the server to speed up initialization. 58 | this._initialCode = text; 59 | } 60 | 61 | static async getPyrightServiceStatus(): Promise { 62 | const endpoint = appServerApiAddressPrefix + `status`; 63 | return endpointGet(endpoint, {}) 64 | .then(async (response) => { 65 | const data = await response.json(); 66 | if (!response.ok) { 67 | throw data; 68 | } 69 | return { pyrightVersions: data.pyrightVersions }; 70 | }) 71 | .catch((err) => { 72 | throw err; 73 | }); 74 | } 75 | 76 | async getDiagnostics(code: string): Promise { 77 | return this._doWithSession(async (sessionId) => { 78 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}/diagnostics`; 79 | return endpointPost(endpoint, {}, JSON.stringify({ code })) 80 | .then(async (response) => { 81 | const data = await response.json(); 82 | if (!response.ok) { 83 | throw data; 84 | } 85 | return data.diagnostics; 86 | }) 87 | .catch((err) => { 88 | throw err; 89 | }); 90 | }); 91 | } 92 | 93 | async getHoverForPosition(code: string, position: Position): Promise { 94 | return this._doWithSession(async (sessionId) => { 95 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}/hover`; 96 | return endpointPost(endpoint, {}, JSON.stringify({ code, position })) 97 | .then(async (response) => { 98 | const data = await response.json(); 99 | if (!response.ok) { 100 | throw data; 101 | } 102 | return data.hover; 103 | }) 104 | .catch((err) => { 105 | throw err; 106 | }); 107 | }); 108 | } 109 | 110 | async getRenameEditsForPosition( 111 | code: string, 112 | position: Position, 113 | newName: string 114 | ): Promise { 115 | return this._doWithSession(async (sessionId) => { 116 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}/rename`; 117 | return endpointPost(endpoint, {}, JSON.stringify({ code, position, newName })) 118 | .then(async (response) => { 119 | const data = await response.json(); 120 | if (!response.ok) { 121 | throw data; 122 | } 123 | return data.edits; 124 | }) 125 | .catch((err) => { 126 | throw err; 127 | }); 128 | }); 129 | } 130 | 131 | async getSignatureHelpForPosition( 132 | code: string, 133 | position: Position 134 | ): Promise { 135 | return this._doWithSession(async (sessionId) => { 136 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}/signature`; 137 | return endpointPost(endpoint, {}, JSON.stringify({ code, position })) 138 | .then(async (response) => { 139 | const data = await response.json(); 140 | if (!response.ok) { 141 | throw data; 142 | } 143 | return data.signatureHelp; 144 | }) 145 | .catch((err) => { 146 | throw err; 147 | }); 148 | }); 149 | } 150 | 151 | async getCompletionForPosition( 152 | code: string, 153 | position: Position 154 | ): Promise { 155 | return this._doWithSession(async (sessionId) => { 156 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}/completion`; 157 | return endpointPost(endpoint, {}, JSON.stringify({ code, position })) 158 | .then(async (response) => { 159 | const data = await response.json(); 160 | if (!response.ok) { 161 | throw data; 162 | } 163 | return data.completionList; 164 | }) 165 | .catch((err) => { 166 | throw err; 167 | }); 168 | }); 169 | } 170 | 171 | async resolveCompletionItem(item: CompletionItem): Promise { 172 | return this._doWithSession(async (sessionId) => { 173 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}/completionresolve`; 174 | return endpointPost(endpoint, {}, JSON.stringify({ completionItem: item })) 175 | .then(async (response) => { 176 | const data = await response.json(); 177 | if (!response.ok) { 178 | throw data; 179 | } 180 | return data.completionItem; 181 | }) 182 | .catch((err) => { 183 | throw err; 184 | }); 185 | }); 186 | } 187 | 188 | // Establishes a session if necessary and calls the callback to perform some 189 | // work. If the session cannot be established or the call fails, an attempt 190 | // is made to retry the operation with exponential backoff. 191 | private async _doWithSession(callback: (sessionId: string) => Promise): Promise { 192 | let errorCount = 0; 193 | let backoffDelay = 100; 194 | 195 | while (true) { 196 | if (errorCount > maxErrorCount) { 197 | throw new Error('Could not connect to service'); 198 | } 199 | 200 | try { 201 | const sessionId = await this._createSession(); 202 | const result = await callback(sessionId); 203 | 204 | return result; 205 | } catch (err) { 206 | // Throw away the current session. 207 | this._sessionId = undefined; 208 | errorCount++; 209 | } 210 | 211 | await this._sleep(backoffDelay); 212 | 213 | // Exponentially back off. 214 | backoffDelay *= 2; 215 | } 216 | } 217 | 218 | private _sleep(sleepTimeInMs: number): Promise { 219 | return new Promise((resolve) => setTimeout(resolve, sleepTimeInMs)); 220 | } 221 | 222 | private async _createSession(): Promise { 223 | // If there's already a valid session ID, use it. 224 | if (this._sessionId) { 225 | return Promise.resolve(this._sessionId); 226 | } 227 | 228 | const sessionOptions: any = {}; 229 | if (this._settings) { 230 | if (this._settings.pyrightVersion) { 231 | sessionOptions.pyrightVersion = this._settings.pyrightVersion; 232 | } 233 | 234 | if (this._settings.pythonVersion) { 235 | sessionOptions.pythonVersion = this._settings.pythonVersion; 236 | } 237 | 238 | if (this._settings.pythonPlatform) { 239 | sessionOptions.pythonPlatform = this._settings.pythonPlatform; 240 | } 241 | 242 | if (this._settings.strictMode) { 243 | sessionOptions.typeCheckingMode = 'strict'; 244 | } 245 | 246 | sessionOptions.code = this._initialCode; 247 | sessionOptions.configOverrides = { ...this._settings.configOverrides }; 248 | sessionOptions.locale = this._settings.locale ?? navigator.language; 249 | } 250 | 251 | const endpoint = appServerApiAddressPrefix + `session`; 252 | const sessionId = await endpointPost(endpoint, {}, JSON.stringify(sessionOptions)).then( 253 | async (response) => { 254 | const data = await response.json(); 255 | if (!response.ok) { 256 | throw data; 257 | } 258 | return data.sessionId; 259 | } 260 | ); 261 | 262 | this._sessionId = sessionId; 263 | return sessionId; 264 | } 265 | 266 | private async _closeSession(): Promise { 267 | const sessionId = this._sessionId; 268 | if (!sessionId) { 269 | return; 270 | } 271 | 272 | // Immediately discard the old session ID. 273 | this._sessionId = undefined; 274 | 275 | const endpoint = appServerApiAddressPrefix + `session/${sessionId}`; 276 | await endpointDelete(endpoint, {}); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /client/Menu.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Provides rendering of (and interaction with) a menu of options. 4 | */ 5 | 6 | import { IconDefinition } from '@ant-design/icons-svg/lib/types'; 7 | import React, { ForwardedRef, forwardRef, useImperativeHandle, useRef } from 'react'; 8 | import { StyleSheet, Text, View } from 'react-native'; 9 | import { 10 | MenuOption, 11 | MenuOptions, 12 | MenuTrigger, 13 | Menu as RNMenu, 14 | renderers, 15 | } from 'react-native-popup-menu'; 16 | import { useHover } from './HoverHook'; 17 | import { SvgIcon } from './SvgIcon'; 18 | 19 | export const menuIconColor = '#669'; 20 | export const panelTextColor = '#222'; 21 | export const focusedMenuItemBackgroundColor = '#eee'; 22 | 23 | export interface MenuProps extends React.PropsWithChildren { 24 | name: string; 25 | onOpen?: () => void; 26 | isPopup?: boolean; 27 | } 28 | 29 | export interface MenuRef { 30 | open: () => void; 31 | close: () => void; 32 | } 33 | 34 | export const Menu = forwardRef(function Menu(props: MenuProps, ref: ForwardedRef) { 35 | const menuRef = useRef(null); 36 | 37 | useImperativeHandle(ref, () => { 38 | return { 39 | open: () => { 40 | menuRef.current?.open(); 41 | }, 42 | close: () => { 43 | menuRef.current?.close(); 44 | }, 45 | }; 46 | }); 47 | 48 | return ( 49 | 57 | 58 | 70 | {props.children} 71 | 72 | 73 | ); 74 | }); 75 | 76 | export interface MenuItemProps { 77 | label: string; 78 | labelFilterText?: string; 79 | title?: string; 80 | iconDefinition?: IconDefinition; 81 | disabled?: boolean; 82 | focused?: boolean; 83 | onSelect?: () => void; 84 | } 85 | 86 | export function MenuItem(props: MenuItemProps) { 87 | const [hoverRef, isHovered] = useHover(); 88 | 89 | // If there's a label filter, see if we can find it in the label. 90 | let filterOffset = -1; 91 | if (props.labelFilterText) { 92 | filterOffset = props.label.toLowerCase().indexOf(props.labelFilterText); 93 | } 94 | 95 | let labelItem: JSX.Element | undefined; 96 | 97 | if (filterOffset < 0) { 98 | labelItem = ( 99 | 100 | {props.label} 101 | 102 | ); 103 | } else { 104 | const beforeText = props.label.substring(0, filterOffset); 105 | const middleText = props.label.substring( 106 | filterOffset, 107 | filterOffset + props.labelFilterText.length 108 | ); 109 | const afterText = props.label.substring(filterOffset + props.labelFilterText.length); 110 | 111 | labelItem = ( 112 | 113 | {beforeText} 114 | 115 | {middleText} 116 | 117 | {afterText} 118 | 119 | ); 120 | } 121 | 122 | return ( 123 | 129 |
130 | 140 | 141 | {props.iconDefinition ? ( 142 | 147 | ) : undefined} 148 | 149 | {labelItem} 150 | 151 |
152 |
153 | ); 154 | } 155 | 156 | const styles = StyleSheet.create({ 157 | container: { 158 | paddingVertical: 2, 159 | paddingHorizontal: 6, 160 | flexDirection: 'row', 161 | alignItems: 'center', 162 | borderRadius: 4, 163 | cursor: 'pointer', 164 | }, 165 | disabled: { 166 | cursor: 'default', 167 | opacity: 0.5, 168 | }, 169 | iconContainer: { 170 | minWidth: 14, 171 | marginLeft: 2, 172 | marginRight: 4, 173 | }, 174 | focused: { 175 | backgroundColor: focusedMenuItemBackgroundColor, 176 | }, 177 | menuContainer: { 178 | margin: 4, 179 | }, 180 | labelText: { 181 | fontSize: 13, 182 | padding: 4, 183 | color: panelTextColor, 184 | }, 185 | labelFiltered: { 186 | backgroundColor: '#ccc', 187 | color: '#000', 188 | }, 189 | }); 190 | -------------------------------------------------------------------------------- /client/MonacoEditor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Wrapper interface around the monaco editor component. This class 4 | * handles language server interactions, the display of errors, etc. 5 | */ 6 | 7 | import Editor, { loader } from '@monaco-editor/react'; 8 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; 9 | import { ForwardedRef, forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; 10 | import { StyleSheet, View } from 'react-native'; 11 | import { 12 | CompletionItem, 13 | CompletionItemKind, 14 | Diagnostic, 15 | DiagnosticSeverity, 16 | InsertReplaceEdit, 17 | Range, 18 | TextDocumentEdit, 19 | } from 'vscode-languageserver-types'; 20 | import { LspClient } from './LspClient'; 21 | 22 | loader 23 | .init() 24 | .then((monaco) => { 25 | monaco.languages.registerHoverProvider('python', { 26 | provideHover: handleHoverRequest, 27 | }); 28 | monaco.languages.registerSignatureHelpProvider('python', { 29 | provideSignatureHelp: handleSignatureHelpRequest, 30 | signatureHelpTriggerCharacters: ['(', ','], 31 | }); 32 | monaco.languages.registerCompletionItemProvider('python', { 33 | provideCompletionItems: handleProvideCompletionRequest, 34 | resolveCompletionItem: handleResolveCompletionRequest, 35 | triggerCharacters: ['.', '[', '"', "'"], 36 | }); 37 | monaco.languages.registerRenameProvider('python', { 38 | provideRenameEdits: handleRenameRequest, 39 | }); 40 | }) 41 | .catch((error) => console.error('An error occurred during initialization of Monaco: ', error)); 42 | 43 | const options: monaco.editor.IStandaloneEditorConstructionOptions = { 44 | selectOnLineNumbers: true, 45 | minimap: { enabled: false }, 46 | fixedOverflowWidgets: true, 47 | tabCompletion: 'on', 48 | hover: { enabled: true }, 49 | scrollBeyondLastLine: false, 50 | autoClosingOvertype: 'always', 51 | autoSurround: 'quotes', 52 | autoIndent: 'full', 53 | // The default settings prefer "Menlo", but "Monaco" looks better 54 | // for our purposes. Swap the order so Monaco is used if available. 55 | fontFamily: 'Monaco, Menlo, "Courier New", monospace', 56 | showUnused: true, 57 | wordBasedSuggestions: false, 58 | overviewRulerLanes: 0, 59 | renderWhitespace: 'none', 60 | guides: { 61 | indentation: false, 62 | }, 63 | renderLineHighlight: 'none', 64 | }; 65 | 66 | interface RegisteredModel { 67 | model: monaco.editor.ITextModel; 68 | lspClient: LspClient; 69 | } 70 | const registeredModels: RegisteredModel[] = []; 71 | 72 | export interface MonacoEditorProps { 73 | lspClient: LspClient; 74 | code: string; 75 | diagnostics: Diagnostic[]; 76 | 77 | onUpdateCode: (code: string) => void; 78 | } 79 | 80 | export interface MonacoEditorRef { 81 | focus: () => void; 82 | selectRange: (range: Range) => void; 83 | } 84 | 85 | export const MonacoEditor = forwardRef(function MonacoEditor( 86 | props: MonacoEditorProps, 87 | ref: ForwardedRef 88 | ) { 89 | const editorRef = useRef(null); 90 | const monacoRef = useRef(null); 91 | 92 | function handleEditorDidMount( 93 | editor: monaco.editor.IStandaloneCodeEditor, 94 | monacoInstance: any 95 | ) { 96 | editorRef.current = editor; 97 | monacoRef.current = monacoInstance; 98 | 99 | editor.focus(); 100 | } 101 | 102 | useImperativeHandle(ref, () => { 103 | return { 104 | focus: () => { 105 | const editor: monaco.editor.IStandaloneCodeEditor = editorRef.current; 106 | if (editor) { 107 | editor.focus(); 108 | } 109 | }, 110 | selectRange: (range: Range) => { 111 | const editor: monaco.editor.IStandaloneCodeEditor = editorRef.current; 112 | if (editor) { 113 | const monacoRange = convertRange(range); 114 | editor.setSelection(monacoRange); 115 | editor.revealLineInCenterIfOutsideViewport(monacoRange.startLineNumber); 116 | } 117 | }, 118 | }; 119 | }); 120 | 121 | useEffect(() => { 122 | if (monacoRef?.current && editorRef?.current) { 123 | const model: monaco.editor.ITextModel = editorRef.current.getModel(); 124 | setFileMarkers(monacoRef.current, model, props.diagnostics); 125 | 126 | // Register the editor and the LSP Client so they can be accessed 127 | // by the hover provider, etc. 128 | registerModel(model, props.lspClient); 129 | } 130 | }, [props.diagnostics]); 131 | 132 | return ( 133 | 134 | 135 | { 141 | props.onUpdateCode(value); 142 | }} 143 | onMount={handleEditorDidMount} 144 | /> 145 | 146 | 147 | ); 148 | }); 149 | 150 | function setFileMarkers( 151 | monacoInstance: any, 152 | model: monaco.editor.ITextModel, 153 | diagnostics: Diagnostic[] 154 | ) { 155 | const markers: monaco.editor.IMarkerData[] = []; 156 | 157 | diagnostics.forEach((diag) => { 158 | const markerData: monaco.editor.IMarkerData = { 159 | ...convertRange(diag.range), 160 | severity: convertSeverity(diag.severity), 161 | message: diag.message, 162 | }; 163 | 164 | if (diag.tags) { 165 | markerData.tags = diag.tags; 166 | } 167 | markers.push(markerData); 168 | }); 169 | 170 | monacoInstance.editor.setModelMarkers(model, 'pyright', markers); 171 | } 172 | 173 | function convertSeverity(severity: DiagnosticSeverity): monaco.MarkerSeverity { 174 | switch (severity) { 175 | case DiagnosticSeverity.Error: 176 | default: 177 | return monaco.MarkerSeverity.Error; 178 | 179 | case DiagnosticSeverity.Warning: 180 | return monaco.MarkerSeverity.Warning; 181 | 182 | case DiagnosticSeverity.Information: 183 | return monaco.MarkerSeverity.Info; 184 | 185 | case DiagnosticSeverity.Hint: 186 | return monaco.MarkerSeverity.Hint; 187 | } 188 | } 189 | 190 | function convertRange(range: Range): monaco.IRange { 191 | return { 192 | startLineNumber: range.start.line + 1, 193 | startColumn: range.start.character + 1, 194 | endLineNumber: range.end.line + 1, 195 | endColumn: range.end.character + 1, 196 | }; 197 | } 198 | 199 | async function handleHoverRequest( 200 | model: monaco.editor.ITextModel, 201 | position: monaco.Position 202 | ): Promise { 203 | const lspClient = getLspClientForModel(model); 204 | if (!lspClient) { 205 | return null; 206 | } 207 | 208 | try { 209 | const hoverInfo = await lspClient.getHoverForPosition(model.getValue(), { 210 | line: position.lineNumber - 1, 211 | character: position.column - 1, 212 | }); 213 | 214 | return { 215 | contents: [ 216 | { 217 | value: hoverInfo.contents.value, 218 | }, 219 | ], 220 | range: convertRange(hoverInfo.range), 221 | }; 222 | } catch (err) { 223 | return null; 224 | } 225 | } 226 | 227 | async function handleRenameRequest( 228 | model: monaco.editor.ITextModel, 229 | position: monaco.Position, 230 | newName: string 231 | ): Promise { 232 | const lspClient = getLspClientForModel(model); 233 | if (!lspClient) { 234 | return null; 235 | } 236 | 237 | try { 238 | const renameEdits = await lspClient.getRenameEditsForPosition( 239 | model.getValue(), 240 | { 241 | line: position.lineNumber - 1, 242 | character: position.column - 1, 243 | }, 244 | newName 245 | ); 246 | 247 | const edits: monaco.languages.IWorkspaceTextEdit[] = []; 248 | 249 | if (renameEdits?.documentChanges) { 250 | for (const docChange of renameEdits.documentChanges) { 251 | if (TextDocumentEdit.is(docChange)) { 252 | for (const textEdit of docChange.edits) { 253 | edits.push({ 254 | resource: model.uri, 255 | versionId: undefined, 256 | textEdit: { 257 | range: convertRange(textEdit.range), 258 | text: textEdit.newText, 259 | }, 260 | }); 261 | } 262 | } 263 | } 264 | } 265 | 266 | return { edits }; 267 | } catch (err) { 268 | return null; 269 | } 270 | } 271 | 272 | async function handleSignatureHelpRequest( 273 | model: monaco.editor.ITextModel, 274 | position: monaco.Position 275 | ): Promise { 276 | const lspClient = getLspClientForModel(model); 277 | if (!lspClient) { 278 | return null; 279 | } 280 | 281 | try { 282 | const sigInfo = await lspClient.getSignatureHelpForPosition(model.getValue(), { 283 | line: position.lineNumber - 1, 284 | character: position.column - 1, 285 | }); 286 | 287 | return { 288 | value: { 289 | signatures: sigInfo.signatures.map((sig) => { 290 | return { 291 | label: sig.label, 292 | documentation: sig.documentation, 293 | parameters: sig.parameters, 294 | activeParameter: sig.activeParameter, 295 | }; 296 | }), 297 | activeSignature: sigInfo.activeSignature ?? 0, 298 | activeParameter: sigInfo.activeParameter, 299 | }, 300 | dispose: () => {}, 301 | }; 302 | } catch (err) { 303 | return null; 304 | } 305 | } 306 | 307 | async function handleProvideCompletionRequest( 308 | model: monaco.editor.ITextModel, 309 | position: monaco.Position 310 | ): Promise { 311 | const lspClient = getLspClientForModel(model); 312 | if (!lspClient) { 313 | return null; 314 | } 315 | 316 | try { 317 | const completionInfo = await lspClient.getCompletionForPosition(model.getValue(), { 318 | line: position.lineNumber - 1, 319 | character: position.column - 1, 320 | }); 321 | 322 | return { 323 | suggestions: completionInfo.items.map((item) => { 324 | return convertCompletionItem(item, model); 325 | }), 326 | incomplete: completionInfo.isIncomplete, 327 | dispose: () => {}, 328 | }; 329 | } catch (err) { 330 | return null; 331 | } 332 | } 333 | 334 | async function handleResolveCompletionRequest( 335 | item: monaco.languages.CompletionItem 336 | ): Promise { 337 | const model = (item as any).model as monaco.editor.ITextModel | undefined; 338 | const original = (item as any).__original as CompletionItem | undefined; 339 | if (!model || !original) { 340 | return null; 341 | } 342 | 343 | const lspClient = getLspClientForModel(model); 344 | if (!lspClient) { 345 | return null; 346 | } 347 | 348 | try { 349 | const result = await lspClient.resolveCompletionItem(original); 350 | return convertCompletionItem(result); 351 | } catch (err) { 352 | return null; 353 | } 354 | } 355 | 356 | function convertCompletionItem( 357 | item: CompletionItem, 358 | model?: monaco.editor.ITextModel 359 | ): monaco.languages.CompletionItem { 360 | const converted: monaco.languages.CompletionItem = { 361 | label: item.label, 362 | kind: convertCompletionItemKind(item.kind), 363 | tags: item.tags, 364 | detail: item.detail, 365 | documentation: item.documentation, 366 | sortText: item.sortText, 367 | filterText: item.filterText, 368 | preselect: item.preselect, 369 | insertText: item.label, 370 | range: undefined, 371 | }; 372 | 373 | if (item.textEdit) { 374 | converted.insertText = item.textEdit.newText; 375 | if (InsertReplaceEdit.is(item.textEdit)) { 376 | converted.range = { 377 | insert: convertRange(item.textEdit.insert), 378 | replace: convertRange(item.textEdit.replace), 379 | }; 380 | } else { 381 | converted.range = convertRange(item.textEdit.range); 382 | } 383 | } 384 | 385 | if (item.additionalTextEdits) { 386 | converted.additionalTextEdits = item.additionalTextEdits.map((edit) => { 387 | return { 388 | range: convertRange(edit.range), 389 | text: edit.newText, 390 | }; 391 | }); 392 | } 393 | 394 | // Stash a few additional pieces of information. 395 | (converted as any).__original = item; 396 | if (model) { 397 | (converted as any).model = model; 398 | } 399 | 400 | return converted; 401 | } 402 | 403 | function convertCompletionItemKind( 404 | itemKind: CompletionItemKind 405 | ): monaco.languages.CompletionItemKind { 406 | switch (itemKind) { 407 | case CompletionItemKind.Constant: 408 | return monaco.languages.CompletionItemKind.Constant; 409 | 410 | case CompletionItemKind.Variable: 411 | return monaco.languages.CompletionItemKind.Variable; 412 | 413 | case CompletionItemKind.Function: 414 | return monaco.languages.CompletionItemKind.Function; 415 | 416 | case CompletionItemKind.Field: 417 | return monaco.languages.CompletionItemKind.Field; 418 | 419 | case CompletionItemKind.Keyword: 420 | return monaco.languages.CompletionItemKind.Keyword; 421 | 422 | default: 423 | return monaco.languages.CompletionItemKind.Reference; 424 | } 425 | } 426 | 427 | // Register an instantiated text model (which backs a monaco editor 428 | // instance and its associated LSP client. This is a bit of a hack, 429 | // but it's required to support the various providers (e.g. hover). 430 | function registerModel(model: monaco.editor.ITextModel, lspClient: LspClient) { 431 | if (registeredModels.find((m) => m.model === model)) { 432 | return; 433 | } 434 | 435 | registeredModels.push({ model, lspClient }); 436 | } 437 | 438 | function getLspClientForModel(model: monaco.editor.ITextModel): LspClient | undefined { 439 | return registeredModels.find((m) => m.model === model)?.lspClient; 440 | } 441 | 442 | const styles = StyleSheet.create({ 443 | container: { 444 | flex: 1, 445 | paddingVertical: 4, 446 | }, 447 | editor: { 448 | position: 'absolute', 449 | height: '100%', 450 | width: '100%', 451 | }, 452 | }); 453 | -------------------------------------------------------------------------------- /client/PlaygroundSettings.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Interface that defines the settings for the pyright playground. 4 | */ 5 | 6 | export interface PlaygroundSettings { 7 | strictMode?: boolean; 8 | configOverrides: { [name: string]: boolean }; 9 | pyrightVersion?: string; 10 | pythonVersion?: string; 11 | pythonPlatform?: string; 12 | locale?: string; 13 | } 14 | 15 | export interface PlaygroundState { 16 | code: string; 17 | settings: PlaygroundSettings; 18 | } 19 | -------------------------------------------------------------------------------- /client/ProblemsPanel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Panel that displays a list of diagnostics. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { IconDefinition } from '@ant-design/icons-svg/lib/types'; 8 | import { 9 | ActivityIndicator, 10 | Animated, 11 | Easing, 12 | Pressable, 13 | ScrollView, 14 | StyleSheet, 15 | Text, 16 | View, 17 | } from 'react-native'; 18 | import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver-types'; 19 | import { useHover } from './HoverHook'; 20 | import { useEffect, useRef } from 'react'; 21 | import { SvgIcon } from './SvgIcon'; 22 | 23 | export interface ProblemsPanelProps { 24 | diagnostics: Diagnostic[]; 25 | onSelectRange: (range: Range) => void; 26 | expandProblems: boolean; 27 | displayActivityIndicator: boolean; 28 | } 29 | 30 | const problemsPanelHeight = 200; 31 | const problemsPanelHeightCollapsed = 32; 32 | 33 | export function ProblemsPanel(props: ProblemsPanelProps) { 34 | // We don't display hints in the problems panel. 35 | const filteredDiagnostics = props.diagnostics.filter( 36 | (diag) => diag.severity !== DiagnosticSeverity.Hint 37 | ); 38 | 39 | // Animate the appearance of the problems panel. 40 | const heightAnimation = useRef( 41 | new Animated.Value( 42 | props.expandProblems ? problemsPanelHeight : problemsPanelHeightCollapsed 43 | ) 44 | ).current; 45 | 46 | useEffect(() => { 47 | Animated.timing(heightAnimation, { 48 | toValue: props.expandProblems ? problemsPanelHeight : problemsPanelHeightCollapsed, 49 | duration: 250, 50 | useNativeDriver: false, 51 | easing: Easing.ease, 52 | }).start(); 53 | }, [heightAnimation, props.expandProblems]); 54 | 55 | return ( 56 | 57 | 58 | 59 | {props.expandProblems ? ( 60 | 61 | 62 | Problems 63 | 64 | 65 | 66 | {filteredDiagnostics.length.toString()} 67 | 68 | 69 | {props.displayActivityIndicator ? ( 70 | 71 | 72 | Waiting for server 73 | 74 | 75 | 76 | ) : undefined} 77 | 78 | ) : undefined} 79 | 80 | 81 | {filteredDiagnostics.length > 0 ? ( 82 | filteredDiagnostics.map((diag, index) => { 83 | return ( 84 | 89 | ); 90 | }) 91 | ) : ( 92 | 93 | )} 94 | 95 | 96 | 97 | ); 98 | } 99 | 100 | function ProblemItem(props: { diagnostic: Diagnostic; onSelectRange: (range: Range) => void }) { 101 | const [hoverRef, isHovered] = useHover(); 102 | 103 | return ( 104 | { 111 | props.onSelectRange(props.diagnostic.range); 112 | }} 113 | > 114 | 115 | 116 | 117 |
118 | 119 | 120 | {props.diagnostic.message} 121 | {props.diagnostic.code ? ( 122 | {` (${props.diagnostic.code})`} 125 | ) : undefined} 126 | 127 | 128 |
129 |
130 | ); 131 | } 132 | 133 | function NoProblemsItem() { 134 | return ( 135 | 136 | 137 | 138 | No problems have been detected. 139 | 140 | 141 | 142 | ); 143 | } 144 | 145 | function ProblemIcon(props: { severity: DiagnosticSeverity }) { 146 | let iconDefinition: IconDefinition; 147 | let iconColor: string; 148 | 149 | if (props.severity === DiagnosticSeverity.Warning) { 150 | iconDefinition = icons.WarningOutlined; 151 | iconColor = '#b89500'; 152 | } else if (props.severity === DiagnosticSeverity.Information) { 153 | iconDefinition = icons.InfoCircleOutlined; 154 | iconColor = 'blue'; 155 | } else { 156 | iconDefinition = icons.CloseCircleOutlined; 157 | iconColor = '#e51400'; 158 | } 159 | 160 | return ; 161 | } 162 | 163 | const styles = StyleSheet.create({ 164 | animatedContainer: { 165 | position: 'relative', 166 | alignSelf: 'stretch', 167 | }, 168 | container: { 169 | flexDirection: 'column', 170 | height: problemsPanelHeight, 171 | borderTopColor: '#ccc', 172 | borderTopWidth: 1, 173 | borderStyle: 'solid', 174 | alignSelf: 'stretch', 175 | }, 176 | header: { 177 | height: 32, 178 | paddingHorizontal: 8, 179 | backgroundColor: '#336', 180 | flexDirection: 'row', 181 | alignSelf: 'stretch', 182 | alignItems: 'center', 183 | }, 184 | headerContents: { 185 | flex: 1, 186 | flexDirection: 'row', 187 | alignSelf: 'stretch', 188 | alignItems: 'center', 189 | }, 190 | problemText: { 191 | marginBottom: 2, 192 | fontSize: 13, 193 | color: '#fff', 194 | }, 195 | problemCountText: { 196 | fontSize: 9, 197 | color: 'black', 198 | }, 199 | problemCountBubble: { 200 | marginLeft: 6, 201 | paddingHorizontal: 5, 202 | height: 16, 203 | borderRadius: 8, 204 | backgroundColor: '#fff', 205 | alignItems: 'center', 206 | justifyContent: 'center', 207 | }, 208 | activityContainer: { 209 | flex: 1, 210 | flexDirection: 'row', 211 | marginRight: 4, 212 | justifyContent: 'flex-end', 213 | }, 214 | waitingText: { 215 | fontSize: 12, 216 | color: '#fff', 217 | marginRight: 8, 218 | }, 219 | diagnosticContainer: { 220 | padding: 4, 221 | flexDirection: 'row', 222 | }, 223 | problemContainerHover: { 224 | backgroundColor: '#eee', 225 | }, 226 | diagnosticIconContainer: { 227 | marginTop: 1, 228 | marginRight: 8, 229 | }, 230 | diagnosticTextContainer: {}, 231 | diagnosticText: { 232 | fontSize: 13, 233 | lineHeight: 16, 234 | }, 235 | diagnosticSourceText: { 236 | color: '#aaa', 237 | }, 238 | }); 239 | -------------------------------------------------------------------------------- /client/PushButton.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * A button that displays an icon and handles press and hover events. 4 | */ 5 | 6 | import { Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native'; 7 | import { useHover } from './HoverHook'; 8 | 9 | interface PushButtonProps { 10 | label: string; 11 | disabled?: boolean; 12 | title?: string; 13 | backgroundStyle?: ViewStyle | ViewStyle[]; 14 | textStyle?: TextStyle; 15 | hoverBackgroundStyle?: ViewStyle | ViewStyle[]; 16 | hoverTextStyle?: TextStyle; 17 | onPress: () => void; 18 | } 19 | 20 | export default function PushButton(props: PushButtonProps) { 21 | const [hoverRef, isHovered] = useHover(); 22 | 23 | let effectiveBackgroundStyle: (ViewStyle | ViewStyle[] | undefined)[] = [ 24 | styles.defaultBackground, 25 | props.backgroundStyle, 26 | ]; 27 | let effectiveTextStyle: (TextStyle | undefined)[] = [props.textStyle]; 28 | 29 | if (props.disabled) { 30 | effectiveBackgroundStyle.push(styles.disabledBackground); 31 | effectiveTextStyle.push(styles.disabledText); 32 | } else if (isHovered) { 33 | effectiveBackgroundStyle.push(styles.defaultHoverBackground, props.hoverBackgroundStyle); 34 | effectiveTextStyle.push(props.hoverTextStyle); 35 | } 36 | 37 | return ( 38 |
39 | 45 | 50 | {props.label} 51 | 52 | 53 |
54 | ); 55 | } 56 | 57 | const styles = StyleSheet.create({ 58 | baseBackground: { 59 | flexDirection: 'row', 60 | paddingHorizontal: 12, 61 | paddingVertical: 6, 62 | borderRadius: 4, 63 | borderWidth: 1, 64 | borderColor: '#669', 65 | }, 66 | defaultBackground: { 67 | backgroundColor: '#f8f8ff', 68 | }, 69 | defaultHoverBackground: { 70 | backgroundColor: '#fff', 71 | }, 72 | disabledBackground: { 73 | backgroundColor: 'transparent', 74 | borderColor: '#ccc', 75 | }, 76 | disabledText: { 77 | color: '#ccc', 78 | }, 79 | baseText: { 80 | color: '#333', 81 | fontSize: 13, 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /client/PyrightConfigSettings.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Information about the configuration settings in pyright. 4 | */ 5 | 6 | export interface PyrightConfigSetting { 7 | name: string; 8 | description: string; 9 | isEnabledInStandard: boolean; 10 | isEnabledInStrict: boolean; 11 | } 12 | 13 | export const configSettings: PyrightConfigSetting[] = [ 14 | { 15 | name: 'analyzeUnannotatedFunctions', 16 | description: 'Analyze and report diagnostics for functions that have no annotations', 17 | isEnabledInStandard: true, 18 | isEnabledInStrict: true, 19 | }, 20 | { 21 | name: 'strictParameterNoneValue', 22 | description: 'Allow implicit Optional when default parameter value is None', 23 | isEnabledInStandard: true, 24 | isEnabledInStrict: true, 25 | }, 26 | { 27 | name: 'enableTypeIgnoreComments', 28 | description: 'Allow "# type: ignore" comments', 29 | isEnabledInStandard: true, 30 | isEnabledInStrict: true, 31 | }, 32 | { 33 | name: 'disableBytesTypePromotions', 34 | description: 'Do not treat bytearray and memoryview as implicit subtypes of bytes', 35 | isEnabledInStandard: false, 36 | isEnabledInStrict: true, 37 | }, 38 | { 39 | name: 'strictListInference', 40 | description: 'Infer strict types for list expressions', 41 | isEnabledInStandard: false, 42 | isEnabledInStrict: true, 43 | }, 44 | { 45 | name: 'strictDictionaryInference', 46 | description: 'Infer strict types for dictionary expressions', 47 | isEnabledInStandard: false, 48 | isEnabledInStrict: true, 49 | }, 50 | { 51 | name: 'strictSetInference', 52 | description: 'Infer strict types for set expressions', 53 | isEnabledInStandard: false, 54 | isEnabledInStrict: true, 55 | }, 56 | { 57 | name: 'reportMissingModuleSource', 58 | description: 'Controls reporting of imports that cannot be resolved to source files', 59 | isEnabledInStandard: true, 60 | isEnabledInStrict: true, 61 | }, 62 | { 63 | name: 'reportInvalidTypeForm', 64 | description: 'Controls reporting of type expressions that use an invalid form', 65 | isEnabledInStandard: true, 66 | isEnabledInStrict: true, 67 | }, 68 | { 69 | name: 'reportMissingImports', 70 | description: 'Controls reporting of imports that cannot be resolved', 71 | isEnabledInStandard: true, 72 | isEnabledInStrict: true, 73 | }, 74 | { 75 | name: 'reportUndefinedVariable', 76 | description: 'Controls reporting of attempts to use an undefined variable', 77 | isEnabledInStandard: true, 78 | isEnabledInStrict: true, 79 | }, 80 | { 81 | name: 'reportAssertAlwaysTrue', 82 | description: 'Controls reporting assert expressions that will always evaluate to true', 83 | isEnabledInStandard: true, 84 | isEnabledInStrict: true, 85 | }, 86 | { 87 | name: 'reportInvalidStringEscapeSequence', 88 | description: 'Controls reporting of invalid escape sequences used within string literals', 89 | isEnabledInStandard: true, 90 | isEnabledInStrict: true, 91 | }, 92 | { 93 | name: 'reportInvalidTypeVarUse', 94 | description: 'Controls reporting improper use of type variables within function signatures', 95 | isEnabledInStandard: true, 96 | isEnabledInStrict: true, 97 | }, 98 | { 99 | name: 'reportMissingTypeStubs', 100 | description: 'Controls reporting of imports that cannot be resolved to type stub files', 101 | isEnabledInStandard: true, 102 | isEnabledInStrict: true, 103 | }, 104 | { 105 | name: 'reportSelfClsParameterName', 106 | description: 'Controls reporting assert expressions that will always evaluate to true', 107 | isEnabledInStandard: true, 108 | isEnabledInStrict: true, 109 | }, 110 | { 111 | name: 'reportUnsupportedDunderAll', 112 | description: 'Controls reporting of unsupported operations performed on __all__', 113 | isEnabledInStandard: true, 114 | isEnabledInStrict: true, 115 | }, 116 | { 117 | name: 'reportUnusedExpression', 118 | description: 'Controls reporting of simple expressions whose value is not used in any way', 119 | isEnabledInStandard: true, 120 | isEnabledInStrict: true, 121 | }, 122 | { 123 | name: 'reportWildcardImportFromLibrary', 124 | description: 'Controls reporting of wlidcard import from external library', 125 | isEnabledInStandard: true, 126 | isEnabledInStrict: true, 127 | }, 128 | { 129 | name: 'reportAbstractUsage', 130 | description: 'Controls reporting of attempted instantiation of abstract class', 131 | isEnabledInStandard: true, 132 | isEnabledInStrict: true, 133 | }, 134 | { 135 | name: 'reportArgumentType', 136 | description: 'Controls reporting of incompatible argument type', 137 | isEnabledInStandard: true, 138 | isEnabledInStrict: true, 139 | }, 140 | { 141 | name: 'reportAssignmentType', 142 | description: 'Controls reporting of type incompatibilities for assignments', 143 | isEnabledInStandard: true, 144 | isEnabledInStrict: true, 145 | }, 146 | { 147 | name: 'reportAttributeAccessIssue', 148 | description: 'Controls reporting of issues related to attribute accesses', 149 | isEnabledInStandard: true, 150 | isEnabledInStrict: true, 151 | }, 152 | { 153 | name: 'reportCallIssue', 154 | description: 'Controls reporting of issues related to call expressions and arguments', 155 | isEnabledInStandard: true, 156 | isEnabledInStrict: true, 157 | }, 158 | { 159 | name: 'reportInconsistentOverload', 160 | description: 'Controls reporting of inconsistencies between function overload signatures', 161 | isEnabledInStandard: true, 162 | isEnabledInStrict: true, 163 | }, 164 | { 165 | name: 'reportInvalidTypeArguments', 166 | description: 'Controls reporting of invalid type argument usage', 167 | isEnabledInStandard: true, 168 | isEnabledInStrict: true, 169 | }, 170 | { 171 | name: 'reportAssertTypeFailure', 172 | description: 'Controls reporting of type mismatch detected by typing.assert_type call', 173 | isEnabledInStandard: true, 174 | isEnabledInStrict: true, 175 | }, 176 | { 177 | name: 'reportGeneralTypeIssues', 178 | description: 'Controls reporting of general type issues', 179 | isEnabledInStandard: true, 180 | isEnabledInStrict: true, 181 | }, 182 | { 183 | name: 'reportIndexIssue', 184 | description: 'Controls reporting of issues related to index operations and expressions', 185 | isEnabledInStandard: true, 186 | isEnabledInStrict: true, 187 | }, 188 | { 189 | name: 'reportNoOverloadImplementation', 190 | description: 191 | 'Controls reporting of an overloaded function or method with a missing implementation', 192 | isEnabledInStandard: true, 193 | isEnabledInStrict: true, 194 | }, 195 | { 196 | name: 'reportOperatorIssue', 197 | description: 'Controls reporting of diagnostics related to unary and binary operators', 198 | isEnabledInStandard: true, 199 | isEnabledInStrict: true, 200 | }, 201 | { 202 | name: 'reportOptionalSubscript', 203 | description: 204 | 'Controls reporting of attempts to subscript (index) a variable with Optional type', 205 | isEnabledInStandard: true, 206 | isEnabledInStrict: true, 207 | }, 208 | { 209 | name: 'reportOptionalMemberAccess', 210 | description: 211 | 'Controls reporting of attempts to access a member of a variable with Optional type', 212 | isEnabledInStandard: true, 213 | isEnabledInStrict: true, 214 | }, 215 | { 216 | name: 'reportOptionalCall', 217 | description: 'Controls reporting of attempts to call a variable with Optional type', 218 | isEnabledInStandard: true, 219 | isEnabledInStrict: true, 220 | }, 221 | { 222 | name: 'reportOptionalIterable', 223 | description: 'Controls reporting of attempts to use an Optional type as an iterable value', 224 | isEnabledInStandard: true, 225 | isEnabledInStrict: true, 226 | }, 227 | { 228 | name: 'reportOptionalContextManager', 229 | description: 230 | 'Controls reporting of attempts to use an Optional type as a parameter to a with statement', 231 | isEnabledInStandard: true, 232 | isEnabledInStrict: true, 233 | }, 234 | { 235 | name: 'reportOptionalOperand', 236 | description: 237 | 'Controls reporting of attempts to use an Optional type as an operand for a binary or unary operator', 238 | isEnabledInStandard: true, 239 | isEnabledInStrict: true, 240 | }, 241 | { 242 | name: 'reportRedeclaration', 243 | description: 244 | 'Controls reporting of attempts to declare the type of a symbol multiple times', 245 | isEnabledInStandard: true, 246 | isEnabledInStrict: true, 247 | }, 248 | { 249 | name: 'reportReturnType', 250 | description: 'Controls reporting of function return type incompatibility', 251 | isEnabledInStandard: true, 252 | isEnabledInStrict: true, 253 | }, 254 | { 255 | name: 'reportTypedDictNotRequiredAccess', 256 | description: 257 | 'Controls reporting of attempts to access a non-required key in a TypedDict without a check for its presence', 258 | isEnabledInStandard: true, 259 | isEnabledInStrict: true, 260 | }, 261 | { 262 | name: 'reportPrivateImportUsage', 263 | description: 264 | 'Controls reporting of improper usage of symbol imported from a "py.typed" module that is not re-exported from that module', 265 | isEnabledInStandard: true, 266 | isEnabledInStrict: true, 267 | }, 268 | { 269 | name: 'reportUnboundVariable', 270 | description: 'Controls reporting of attempts to use an unbound variable', 271 | isEnabledInStandard: true, 272 | isEnabledInStrict: true, 273 | }, 274 | { 275 | name: 'reportUnhashable', 276 | description: 277 | 'Controls reporting of attempts to use an unhashable object in a container that requires hashability', 278 | isEnabledInStandard: true, 279 | isEnabledInStrict: true, 280 | }, 281 | { 282 | name: 'reportUnusedCoroutine', 283 | description: 284 | 'Controls reporting of call expressions that returns Coroutine whose results are not consumed', 285 | isEnabledInStandard: true, 286 | isEnabledInStrict: true, 287 | }, 288 | { 289 | name: 'reportConstantRedefinition', 290 | description: 'Controls reporting of attempts to redefine variables that are in all-caps', 291 | isEnabledInStandard: false, 292 | isEnabledInStrict: true, 293 | }, 294 | { 295 | name: 'reportDeprecated', 296 | description: 'Controls reporting of use of deprecated class or function', 297 | isEnabledInStandard: false, 298 | isEnabledInStrict: true, 299 | }, 300 | { 301 | name: 'reportDuplicateImport', 302 | description: 'Controls reporting of symbols or modules that are imported more than once', 303 | isEnabledInStandard: false, 304 | isEnabledInStrict: true, 305 | }, 306 | { 307 | name: 'reportFunctionMemberAccess', 308 | description: 'Controls reporting of member accesses on function objects', 309 | isEnabledInStandard: true, 310 | isEnabledInStrict: true, 311 | }, 312 | { 313 | name: 'reportIncompatibleMethodOverride', 314 | description: 315 | 'Controls reporting of method overrides in subclasses that redefine the method in an incompatible way', 316 | isEnabledInStandard: true, 317 | isEnabledInStrict: true, 318 | }, 319 | { 320 | name: 'reportIncompatibleVariableOverride', 321 | description: 322 | 'Controls reporting of overrides in subclasses that redefine a variable in an incompatible way', 323 | isEnabledInStandard: true, 324 | isEnabledInStrict: true, 325 | }, 326 | { 327 | name: 'reportIncompleteStub', 328 | description: 329 | 'Controls reporting of incomplete type stubs that declare a module-level __getattr__ function', 330 | isEnabledInStandard: false, 331 | isEnabledInStrict: true, 332 | }, 333 | { 334 | name: 'reportInconsistentConstructor', 335 | description: 336 | 'Controls reporting of __init__ and __new__ methods whose signatures are inconsistent', 337 | isEnabledInStandard: false, 338 | isEnabledInStrict: true, 339 | }, 340 | // Stubs are not modeled in the playground, so this setting is not relevant. 341 | // { 342 | // name: 'reportInvalidStubStatement', 343 | // description: 'Controls reporting of type stub statements that do not conform to PEP 484', 344 | // isEnabledInStandard: false, 345 | // isEnabledInStrict: true, 346 | // }, 347 | { 348 | name: 'reportMatchNotExhaustive', 349 | description: 350 | 'Controls reporting of match statements that do not exhaustively match all possible values', 351 | isEnabledInStandard: false, 352 | isEnabledInStrict: true, 353 | }, 354 | { 355 | name: 'reportMissingParameterType', 356 | description: 'Controls reporting input parameters that are missing a type annotation', 357 | isEnabledInStandard: false, 358 | isEnabledInStrict: true, 359 | }, 360 | { 361 | name: 'reportMissingTypeArgument', 362 | description: 'Controls reporting generic class reference with missing type arguments', 363 | isEnabledInStandard: false, 364 | isEnabledInStrict: true, 365 | }, 366 | { 367 | name: 'reportOverlappingOverload', 368 | description: 369 | 'Controls reporting of function overloads that overlap in signature and obscure each other or do not agree on return type', 370 | isEnabledInStandard: true, 371 | isEnabledInStrict: true, 372 | }, 373 | { 374 | name: 'reportPossiblyUnboundVariable', 375 | description: 376 | 'Controls reporting of attempts to use variable that is possibly unbound on some code paths', 377 | isEnabledInStandard: true, 378 | isEnabledInStrict: true, 379 | }, 380 | { 381 | name: 'reportPrivateUsage', 382 | description: 383 | 'Controls reporting of private variables and functions used outside of the owning class or module and usage of protected members outside of subclasses', 384 | isEnabledInStandard: false, 385 | isEnabledInStrict: true, 386 | }, 387 | { 388 | name: 'reportTypeCommentUsage', 389 | description: 'Controls reporting of deprecated type comment usage', 390 | isEnabledInStandard: false, 391 | isEnabledInStrict: true, 392 | }, 393 | { 394 | name: 'reportUnknownArgumentType', 395 | description: 'Controls reporting argument expressions whose types are unknown', 396 | isEnabledInStandard: false, 397 | isEnabledInStrict: true, 398 | }, 399 | { 400 | name: 'reportUnknownLambdaType', 401 | description: 402 | 'Controls reporting input and return parameters for lambdas whose types are unknown', 403 | isEnabledInStandard: false, 404 | isEnabledInStrict: true, 405 | }, 406 | { 407 | name: 'reportUnknownMemberType', 408 | description: 'Controls reporting class and instance variables whose types are unknown', 409 | isEnabledInStandard: false, 410 | isEnabledInStrict: true, 411 | }, 412 | { 413 | name: 'reportUnknownParameterType', 414 | description: 'Controls reporting input and return parameters whose types are unknown', 415 | isEnabledInStandard: false, 416 | isEnabledInStrict: true, 417 | }, 418 | { 419 | name: 'reportUnknownVariableType', 420 | description: 'Controls reporting local variables whose types are unknown', 421 | isEnabledInStandard: false, 422 | isEnabledInStrict: true, 423 | }, 424 | { 425 | name: 'reportUnnecessaryCast', 426 | description: 'Controls reporting calls to "cast" that are unnecessary', 427 | isEnabledInStandard: false, 428 | isEnabledInStrict: true, 429 | }, 430 | { 431 | name: 'reportUnnecessaryComparison', 432 | description: 'Controls reporting the use of "==" or "!=" comparisons that are unnecessary', 433 | isEnabledInStandard: false, 434 | isEnabledInStrict: true, 435 | }, 436 | { 437 | name: 'reportUnnecessaryContains', 438 | description: 'Controls reporting the use of "in" operations that are unnecessary', 439 | isEnabledInStandard: false, 440 | isEnabledInStrict: true, 441 | }, 442 | { 443 | name: 'reportUnnecessaryIsInstance', 444 | description: 445 | 'Controls reporting calls to "isinstance" or "issubclass" where the result is statically determined to be always true', 446 | isEnabledInStandard: false, 447 | isEnabledInStrict: true, 448 | }, 449 | { 450 | name: 'reportUnusedClass', 451 | description: 'Controls reporting of private classes that are not accessed', 452 | isEnabledInStandard: false, 453 | isEnabledInStrict: true, 454 | }, 455 | { 456 | name: 'reportUnusedImport', 457 | description: 458 | 'Controls reporting of imported symbols that are not referenced within the source file', 459 | isEnabledInStandard: false, 460 | isEnabledInStrict: true, 461 | }, 462 | { 463 | name: 'reportUnusedFunction', 464 | description: 'Controls reporting of private functions or methods that are not accessed', 465 | isEnabledInStandard: false, 466 | isEnabledInStrict: true, 467 | }, 468 | { 469 | name: 'reportUnusedVariable', 470 | description: 'Controls reporting of private functions or methods that are not accessed', 471 | isEnabledInStandard: false, 472 | isEnabledInStrict: true, 473 | }, 474 | { 475 | name: 'reportUntypedBaseClass', 476 | description: 477 | 'Controls reporting of a base class of an unknown type, which obscures most type checking for the class', 478 | isEnabledInStandard: false, 479 | isEnabledInStrict: true, 480 | }, 481 | { 482 | name: 'reportUntypedClassDecorator', 483 | description: 484 | 'Controls reporting of class decorators without type annotations, which obscure class types', 485 | isEnabledInStandard: false, 486 | isEnabledInStrict: true, 487 | }, 488 | { 489 | name: 'reportUntypedFunctionDecorator', 490 | description: 491 | 'Controls reporting of function decorators without type annotations, which obscure function types', 492 | isEnabledInStandard: false, 493 | isEnabledInStrict: true, 494 | }, 495 | { 496 | name: 'reportUntypedNamedTuple', 497 | description: 498 | 'Controls reporting of a named tuple definition that does not contain type information', 499 | isEnabledInStandard: false, 500 | isEnabledInStrict: true, 501 | }, 502 | { 503 | name: 'deprecateTypingAliases', 504 | description: 'Treat typing-specific aliases to standard types as deprecated', 505 | isEnabledInStandard: false, 506 | isEnabledInStrict: false, 507 | }, 508 | { 509 | name: 'enableExperimentalFeatures', 510 | description: 511 | 'Enable the use of experimental features that are not part of the Python typing spec', 512 | isEnabledInStandard: false, 513 | isEnabledInStrict: false, 514 | }, 515 | { 516 | name: 'reportCallInDefaultInitializer', 517 | description: 518 | 'Controls reporting usage of function calls within a default value initializer expression', 519 | isEnabledInStandard: false, 520 | isEnabledInStrict: false, 521 | }, 522 | { 523 | name: 'reportImplicitOverride', 524 | description: 525 | 'Controls reporting overridden methods that are missing an "@override" decorator', 526 | isEnabledInStandard: false, 527 | isEnabledInStrict: false, 528 | }, 529 | { 530 | name: 'reportImplicitStringConcatenation', 531 | description: 'Controls reporting usage of implicit concatenation of string literals', 532 | isEnabledInStandard: false, 533 | isEnabledInStrict: false, 534 | }, 535 | { 536 | name: 'reportMissingSuperCall', 537 | description: 538 | 'Controls reporting of missing call to parent class for inherited "__init__" methods', 539 | isEnabledInStandard: false, 540 | isEnabledInStrict: false, 541 | }, 542 | { 543 | name: 'reportPropertyTypeMismatch', 544 | description: 'Controls reporting of property getter/setter type mismatches', 545 | isEnabledInStandard: false, 546 | isEnabledInStrict: false, 547 | }, 548 | { 549 | name: 'reportShadowedImports', 550 | description: 'Controls reporting of shadowed imports of stdlib modules', 551 | isEnabledInStandard: false, 552 | isEnabledInStrict: false, 553 | }, 554 | { 555 | name: 'reportUninitializedInstanceVariable', 556 | description: 557 | 'Controls reporting of instance variables that are not initialized in the constructor', 558 | isEnabledInStandard: false, 559 | isEnabledInStrict: false, 560 | }, 561 | { 562 | name: 'reportUnnecessaryTypeIgnoreComment', 563 | description: 'Controls reporting of "# type: ignore" comments that have no effect', 564 | isEnabledInStandard: false, 565 | isEnabledInStrict: false, 566 | }, 567 | { 568 | name: 'reportUnusedCallResult', 569 | description: 'Controls reporting of call expressions whose results are not consumed', 570 | isEnabledInStandard: false, 571 | isEnabledInStrict: false, 572 | }, 573 | ]; 574 | 575 | export const configSettingsAlphabetized = configSettings.sort((a, b) => { 576 | return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; 577 | }); 578 | 579 | export const configSettingsMap = new Map(); 580 | configSettings.forEach((setting) => { 581 | configSettingsMap.set(setting.name, setting); 582 | }); 583 | -------------------------------------------------------------------------------- /client/RightPanel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Collapsible panel that appears on the right side of the window. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { useEffect, useRef } from 'react'; 8 | import { Animated, Easing, ScrollView, StyleSheet, Text, View } from 'react-native'; 9 | import IconButton from './IconButton'; 10 | import { AboutPanel } from './AboutPanel'; 11 | import { SettingsPanel } from './SettingsPanel'; 12 | import { PlaygroundSettings } from './PlaygroundSettings'; 13 | 14 | export enum RightPanelType { 15 | About, 16 | Settings, 17 | Share, 18 | } 19 | 20 | export interface RightPanelProps { 21 | isRightPanelDisplayed: boolean; 22 | rightPanelType: RightPanelType; 23 | onShowRightPanel: (rightPanelType?: RightPanelType) => void; 24 | settings: PlaygroundSettings; 25 | onUpdateSettings: (settings: PlaygroundSettings) => void; 26 | latestPyrightVersion?: string; 27 | supportedPyrightVersions?: string[]; 28 | code: string; 29 | getShareableUrl: () => string; 30 | } 31 | const rightPanelWidth = 320; 32 | 33 | export function RightPanel(props: RightPanelProps) { 34 | let panelContents: JSX.Element | undefined; 35 | let headerTitle = ''; 36 | 37 | switch (props.rightPanelType) { 38 | case RightPanelType.About: 39 | panelContents = ( 40 | 41 | ); 42 | headerTitle = 'About Pyright Playground'; 43 | break; 44 | 45 | case RightPanelType.Settings: 46 | panelContents = ( 47 | 53 | ); 54 | headerTitle = 'Playground Settings'; 55 | break; 56 | 57 | case RightPanelType.Share: 58 | headerTitle = 'Share Link'; 59 | panelContents = ; 60 | break; 61 | } 62 | 63 | // Animate the appearance or disappearance of the right panel. 64 | const widthAnimation = useRef(new Animated.Value(panelContents ? rightPanelWidth : 0)).current; 65 | 66 | useEffect(() => { 67 | Animated.timing(widthAnimation, { 68 | toValue: props.isRightPanelDisplayed ? rightPanelWidth : 0, 69 | duration: 250, 70 | useNativeDriver: false, 71 | easing: Easing.ease, 72 | }).start(); 73 | }, [widthAnimation, props.isRightPanelDisplayed]); 74 | 75 | return ( 76 | 77 | 78 | 79 | 80 | {headerTitle} 81 | 82 | 83 | { 90 | props.onShowRightPanel(); 91 | }} 92 | /> 93 | 94 | 95 | {panelContents} 96 | 97 | 98 | ); 99 | } 100 | 101 | const styles = StyleSheet.create({ 102 | animatedContainer: { 103 | flexDirection: 'row', 104 | position: 'relative', 105 | }, 106 | container: { 107 | width: rightPanelWidth, 108 | alignSelf: 'stretch', 109 | backgroundColor: '#f8f8ff', 110 | }, 111 | contentContainer: { 112 | flexGrow: 1, 113 | flexShrink: 0, 114 | flexBasis: 0, 115 | flexDirection: 'column', 116 | alignSelf: 'stretch', 117 | }, 118 | headerContainer: { 119 | flexDirection: 'row', 120 | height: 36, 121 | alignItems: 'center', 122 | borderBottomWidth: 1, 123 | borderStyle: 'solid', 124 | borderColor: '#ddd', 125 | paddingLeft: 12, 126 | paddingRight: 4, 127 | }, 128 | headerTitleText: { 129 | color: '#333', 130 | fontSize: 14, 131 | fontWeight: 'bold', 132 | }, 133 | headerControlsContainer: { 134 | flex: 1, 135 | alignItems: 'flex-end', 136 | }, 137 | }); 138 | -------------------------------------------------------------------------------- /client/SettingsCheckBox.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * A simple check box (toggle) control used in the settings panel. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { View, Text, StyleSheet, Pressable } from 'react-native'; 8 | import { useHover } from './HoverHook'; 9 | import { SvgIcon } from './SvgIcon'; 10 | 11 | export interface SettingsCheckboxProps { 12 | label: string; 13 | title: string; 14 | value: boolean; 15 | disabled: boolean; 16 | onChange: (value: boolean) => void; 17 | } 18 | 19 | export function SettingsCheckbox(props: SettingsCheckboxProps) { 20 | const [hoverRef, isHovered] = useHover(); 21 | 22 | return ( 23 | { 28 | props.onChange(!props.value); 29 | }} 30 | disabled={props.disabled} 31 | > 32 | 39 | {props.value ? ( 40 | 45 | ) : undefined} 46 | 47 |
48 | 55 | {props.label} 56 | 57 |
58 |
59 | ); 60 | } 61 | 62 | const styles = StyleSheet.create({ 63 | container: { 64 | flex: 1, 65 | flexDirection: 'row', 66 | paddingVertical: 4, 67 | paddingHorizontal: 16, 68 | alignItems: 'center', 69 | alignSelf: 'flex-start', 70 | }, 71 | checkbox: { 72 | width: 16, 73 | height: 16, 74 | paddingVertical: 1, 75 | paddingHorizontal: 1, 76 | borderColor: '#333', 77 | borderWidth: 1, 78 | borderStyle: 'solid', 79 | }, 80 | checkboxDisabled: { 81 | borderColor: '#aaa', 82 | }, 83 | checkboxHover: { 84 | backgroundColor: '#fff', 85 | }, 86 | checkboxText: { 87 | flex: -1, 88 | marginLeft: 8, 89 | fontSize: 13, 90 | color: '#333', 91 | }, 92 | checkboxTextDisabled: { 93 | color: '#ccc', 94 | }, 95 | }); 96 | -------------------------------------------------------------------------------- /client/SettingsPanel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * A panel that displays settings for the app. 4 | */ 5 | 6 | import * as icons from '@ant-design/icons-svg'; 7 | import { useRef } from 'react'; 8 | import { StyleSheet, Text, View } from 'react-native'; 9 | import { CheckmarkMenu, CheckmarkMenuItem } from './CheckmarkMenu'; 10 | import IconButton from './IconButton'; 11 | import { getLocaleDisplayName, supportedLocales } from './Locales'; 12 | import { Menu, MenuRef } from './Menu'; 13 | import { PlaygroundSettings } from './PlaygroundSettings'; 14 | import PushButton from './PushButton'; 15 | import { 16 | PyrightConfigSetting, 17 | configSettings, 18 | configSettingsAlphabetized, 19 | } from './PyrightConfigSettings'; 20 | import { SettingsCheckbox } from './SettingsCheckBox'; 21 | 22 | interface ConfigOptionWithValue { 23 | name: string; 24 | value: boolean; 25 | } 26 | 27 | export interface SettingsPanelProps { 28 | settings: PlaygroundSettings; 29 | latestPyrightVersion?: string; 30 | supportedPyrightVersions?: string[]; 31 | onUpdateSettings: (settings: PlaygroundSettings) => void; 32 | } 33 | 34 | export function SettingsPanel(props: SettingsPanelProps) { 35 | const configOptionsMenuRef = useRef(null); 36 | const pyrightVersionMenuRef = useRef(null); 37 | const pythonVersionMenuRef = useRef(null); 38 | const pythonPlatformMenuRef = useRef(null); 39 | const localeMenuRef = useRef(null); 40 | const configOverrides = getNonDefaultConfigOptions(props.settings); 41 | 42 | return ( 43 | 44 | 45 | { 52 | props.onUpdateSettings({ 53 | ...props.settings, 54 | strictMode: !props.settings.strictMode, 55 | }); 56 | }} 57 | /> 58 | 59 | 60 | 61 | {configOverrides.length === 0 ? 'Default' : 'Custom'} 62 | 63 | { 65 | configOptionsMenuRef.current?.open(); 66 | }} 67 | /> 68 | 69 | { 71 | return getConfigOptionMenuItem(props.settings, item); 72 | })} 73 | onSelect={(item) => { 74 | props.onUpdateSettings(toggleConfigOption(props.settings, item.label)); 75 | }} 76 | includeSearchBox={true} 77 | fixedSize={{ width: 300, height: 400 }} 78 | onDismiss={() => { 79 | configOptionsMenuRef.current?.close(); 80 | }} 81 | /> 82 | 83 | 84 | 85 | {configOverrides.map((config) => { 86 | return ( 87 | { 91 | const configOverrides = { ...props.settings.configOverrides }; 92 | delete configOverrides[config.name]; 93 | 94 | props.onUpdateSettings({ 95 | ...props.settings, 96 | configOverrides, 97 | }); 98 | }} 99 | /> 100 | ); 101 | })} 102 | 103 | 104 | 105 | 106 | 107 | 108 | {props.settings.pyrightVersion || 109 | (props.latestPyrightVersion 110 | ? `Latest (${props.latestPyrightVersion})` 111 | : 'Latest')} 112 | 113 | { 115 | pyrightVersionMenuRef.current?.open(); 116 | }} 117 | /> 118 | 119 | { 121 | return { 122 | label: item, 123 | checked: item === (props.settings.pyrightVersion ?? 'Latest'), 124 | }; 125 | })} 126 | onSelect={(item, index) => { 127 | props.onUpdateSettings({ 128 | ...props.settings, 129 | pyrightVersion: index > 0 ? item.label : undefined, 130 | }); 131 | }} 132 | /> 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | {props.settings.pythonVersion || 'Default (3.13)'} 141 | 142 | { 144 | pythonVersionMenuRef.current?.open(); 145 | }} 146 | /> 147 | 148 | { 161 | return { 162 | label: item, 163 | checked: item === (props.settings.pythonVersion ?? 'Default'), 164 | }; 165 | })} 166 | onSelect={(item, index) => { 167 | props.onUpdateSettings({ 168 | ...props.settings, 169 | pythonVersion: index > 0 ? item.label : undefined, 170 | }); 171 | }} 172 | /> 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | {props.settings.pythonPlatform || 'Default (All)'} 181 | 182 | { 184 | pythonPlatformMenuRef.current?.open(); 185 | }} 186 | /> 187 | 188 | { 190 | return { 191 | label: item, 192 | checked: item === (props.settings.pythonPlatform ?? 'All'), 193 | }; 194 | })} 195 | onSelect={(item, index) => { 196 | props.onUpdateSettings({ 197 | ...props.settings, 198 | pythonPlatform: index > 0 ? item.label : undefined, 199 | }); 200 | }} 201 | /> 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | {getLocaleDisplayName(props.settings.locale) || 'Browser Default'} 210 | 211 | { 213 | localeMenuRef.current?.open(); 214 | }} 215 | /> 216 | 217 | { 219 | return { 220 | label: locale.displayName, 221 | checked: locale.code === (props.settings.locale ?? ''), 222 | }; 223 | })} 224 | onSelect={(item, index) => { 225 | props.onUpdateSettings({ 226 | ...props.settings, 227 | locale: index > 0 ? supportedLocales[index].code : undefined, 228 | }); 229 | }} 230 | /> 231 | 232 | 233 | 234 | 235 | 236 | { 241 | props.onUpdateSettings({ 242 | configOverrides: {}, 243 | }); 244 | }} 245 | /> 246 | 247 | 248 | ); 249 | } 250 | 251 | function MenuButton(props: { onPress: () => void }) { 252 | return ( 253 | 260 | ); 261 | } 262 | 263 | function SettingsHeader(props: { headerText: string }) { 264 | return ( 265 | 266 | 267 | {props.headerText} 268 | 269 | 270 | ); 271 | } 272 | 273 | function SettingsDivider() { 274 | return ; 275 | } 276 | 277 | interface ConfigOverrideProps { 278 | config: ConfigOptionWithValue; 279 | onRemove: () => void; 280 | } 281 | 282 | function ConfigOverride(props: ConfigOverrideProps) { 283 | const text = `${props.config.name}=${props.config.value.toString()}`; 284 | 285 | return ( 286 | 287 | 288 | {text} 289 | 290 | 291 | 298 | 299 | 300 | ); 301 | } 302 | 303 | function areSettingsDefault(settings: PlaygroundSettings): boolean { 304 | return ( 305 | Object.keys(settings.configOverrides).length === 0 && 306 | !settings.strictMode && 307 | settings.pyrightVersion === undefined && 308 | settings.pythonVersion === undefined && 309 | settings.pythonPlatform === undefined && 310 | settings.locale === undefined 311 | ); 312 | } 313 | 314 | function getNonDefaultConfigOptions(settings: PlaygroundSettings): ConfigOptionWithValue[] { 315 | const overrides: ConfigOptionWithValue[] = []; 316 | 317 | configSettingsAlphabetized.forEach((configInfo) => { 318 | // If strict mode is in effect, don't consider overrides if the 319 | // config option is always on in strict mode. 320 | if (settings.strictMode && configInfo.isEnabledInStrict) { 321 | return; 322 | } 323 | 324 | const defaultValue = configInfo.isEnabledInStandard; 325 | const overrideValue = settings.configOverrides[configInfo.name] ?? defaultValue; 326 | 327 | if (defaultValue !== overrideValue) { 328 | overrides.push({ name: configInfo.name, value: overrideValue }); 329 | } 330 | }); 331 | 332 | return overrides; 333 | } 334 | 335 | function getConfigOptionMenuItem( 336 | settings: PlaygroundSettings, 337 | config: PyrightConfigSetting 338 | ): CheckmarkMenuItem { 339 | const isEnabled = settings.configOverrides[config.name] ?? config.isEnabledInStandard; 340 | 341 | return { 342 | label: config.name, 343 | checked: isEnabled || (config.isEnabledInStrict && settings.strictMode), 344 | disabled: config.isEnabledInStrict && settings.strictMode, 345 | title: config.description, 346 | }; 347 | } 348 | 349 | function toggleConfigOption(settings: PlaygroundSettings, optionName: string): PlaygroundSettings { 350 | const configOverrides = { ...settings.configOverrides }; 351 | const configInfo = configSettings.find((s) => s.name === optionName); 352 | const isEnabledByDefault = configInfo?.isEnabledInStandard; 353 | const isEnabled = configOverrides[optionName] ?? isEnabledByDefault; 354 | 355 | if (isEnabledByDefault === !isEnabled) { 356 | // If the new value matches the default value, delete it 357 | // to restore the default. 358 | delete configOverrides[optionName]; 359 | } else { 360 | configOverrides[optionName] = !isEnabled; 361 | } 362 | 363 | return { ...settings, configOverrides }; 364 | } 365 | 366 | const styles = StyleSheet.create({ 367 | container: { 368 | flex: 1, 369 | flexDirection: 'column', 370 | alignSelf: 'stretch', 371 | paddingVertical: 8, 372 | paddingHorizontal: 12, 373 | }, 374 | divider: { 375 | height: 1, 376 | borderTopWidth: 1, 377 | borderColor: '#eee', 378 | borderStyle: 'solid', 379 | marginVertical: 8, 380 | }, 381 | headerTextBox: { 382 | marginBottom: 4, 383 | }, 384 | headerText: { 385 | fontSize: 14, 386 | color: '#666', 387 | fontVariant: ['small-caps'], 388 | }, 389 | resetButtonContainer: { 390 | alignSelf: 'center', 391 | marginTop: 4, 392 | marginHorizontal: 8, 393 | }, 394 | selectionContainer: { 395 | height: 24, 396 | paddingTop: 6, 397 | paddingBottom: 2, 398 | paddingHorizontal: 16, 399 | alignItems: 'center', 400 | flexDirection: 'row', 401 | }, 402 | selectedOptionText: { 403 | fontSize: 13, 404 | color: '#333', 405 | flex: 1, 406 | }, 407 | overridesContainer: { 408 | flexDirection: 'column', 409 | marginTop: 4, 410 | }, 411 | configOverrideContainer: { 412 | flexDirection: 'row', 413 | alignItems: 'center', 414 | justifyContent: 'space-between', 415 | marginLeft: 16, 416 | paddingHorizontal: 16, 417 | paddingVertical: 4, 418 | }, 419 | configOverrideText: { 420 | flex: -1, 421 | fontSize: 12, 422 | color: '#333', 423 | }, 424 | }); 425 | -------------------------------------------------------------------------------- /client/SvgIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * An icon rendered using an SVG image. 4 | */ 5 | 6 | import { renderIconDefinitionToSVGElement } from '@ant-design/icons-svg/es/helpers'; 7 | import { IconDefinition } from '@ant-design/icons-svg/lib/types'; 8 | import { StyleSheet, View } from 'react-native'; 9 | 10 | export interface SvgIconProps { 11 | iconDefinition: IconDefinition; 12 | iconSize: number; 13 | color: string; 14 | } 15 | 16 | export function SvgIcon(props: SvgIconProps) { 17 | const svgElement = renderIconDefinitionToSVGElement(props.iconDefinition, { 18 | extraSVGAttrs: { 19 | width: `${props.iconSize}px`, 20 | height: `${props.iconSize}px`, 21 | fill: props.color, 22 | }, 23 | }); 24 | 25 | return ( 26 | 27 |
36 | 37 | ); 38 | } 39 | 40 | const styles = StyleSheet.create({ 41 | defaultBackgroundStyle: { 42 | paddingHorizontal: 6, 43 | paddingVertical: 2, 44 | }, 45 | container: { 46 | flex: -1, 47 | justifyContent: 'center', 48 | }, 49 | iconContainer: { 50 | alignItems: 'center', 51 | justifyContent: 'center', 52 | }, 53 | disabled: { 54 | opacity: 1, 55 | cursor: 'default', 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /client/TextWithLink.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * View that displays text as a link that opens a URL. 4 | */ 5 | 6 | import { Linking, StyleProp, StyleSheet, Text, TextStyle } from 'react-native'; 7 | import { useHover } from './HoverHook'; 8 | 9 | interface TextWithLinkProps extends React.PropsWithChildren { 10 | style?: StyleProp; 11 | url: string; 12 | useSameWindow?: boolean; 13 | } 14 | 15 | export default function TextWithLink(props: TextWithLinkProps) { 16 | const [hoverRef, isHovered] = useHover(); 17 | 18 | return ( 19 | { 23 | if (props.useSameWindow && !(event as any).metaKey) { 24 | history.pushState(null, '', window.location.href); 25 | window.location.replace(props.url); 26 | } else { 27 | Linking.openURL(props.url); 28 | } 29 | }} 30 | > 31 | {props.children} 32 | 33 | ); 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | default: { 38 | color: '#558', 39 | }, 40 | defaultHover: { 41 | color: '#333', 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /client/UrlUtils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Utility routines for reading and updating the URL in the browser. 4 | */ 5 | 6 | import * as lzString from 'lz-string'; 7 | import { PlaygroundState } from './PlaygroundSettings'; 8 | import { configSettingsMap } from './PyrightConfigSettings'; 9 | 10 | export function updateUrlFromState(state: PlaygroundState): string { 11 | const { code, settings } = state; 12 | const url = new URL(window.location.href); 13 | 14 | // Delete all of the existing query parameters. 15 | url.searchParams.forEach((_, key) => { 16 | url.searchParams.delete(key); 17 | }); 18 | 19 | url.search = ''; 20 | 21 | if (settings) { 22 | if (settings.pyrightVersion) { 23 | url.searchParams.set('pyrightVersion', settings.pyrightVersion); 24 | } 25 | 26 | if (settings.pythonVersion) { 27 | url.searchParams.set('pythonVersion', settings.pythonVersion); 28 | } 29 | 30 | if (settings.pythonPlatform) { 31 | url.searchParams.set('pythonPlatform', settings.pythonPlatform); 32 | } 33 | 34 | if (settings.strictMode) { 35 | url.searchParams.set('strict', 'true'); 36 | } 37 | 38 | if (settings.locale) { 39 | url.searchParams.set('locale', settings.locale); 40 | } 41 | 42 | Object.keys(settings.configOverrides).forEach((key) => { 43 | const value = settings.configOverrides[key]; 44 | url.searchParams.set(key, value.toString()); 45 | }); 46 | 47 | if (code) { 48 | // Use compression for the code. 49 | const encodedCode = lzString.compressToEncodedURIComponent(code); 50 | url.searchParams.set('code', encodedCode); 51 | } 52 | } 53 | 54 | // Firefox throws an exception in safe mode if this call is made 55 | // too often. To prevent a crash, catch the exception and ignore it. 56 | try { 57 | window.history.pushState(null, null, url.toString()); 58 | } catch { 59 | // Do nothing. 60 | } 61 | 62 | // Replace the domain name with the canonical one before 63 | // returning the shareable URL. 64 | url.host = 'pyright-play.net'; 65 | url.protocol = 'https'; 66 | url.port = ''; 67 | return url.toString(); 68 | } 69 | 70 | export function getStateFromUrl(): PlaygroundState | undefined { 71 | const url = new URL(window.location.href); 72 | 73 | const compressedCode = url.searchParams.get('code'); 74 | if (!compressedCode) { 75 | return undefined; 76 | } 77 | const code = lzString.decompressFromEncodedURIComponent(compressedCode); 78 | if (!code) { 79 | return undefined; 80 | } 81 | 82 | const state: PlaygroundState = { 83 | code, 84 | settings: { 85 | configOverrides: {}, 86 | }, 87 | }; 88 | 89 | url.searchParams.forEach((value, key) => { 90 | switch (key) { 91 | case 'strict': { 92 | if (Boolean(value)) { 93 | state.settings.strictMode = true; 94 | } 95 | break; 96 | } 97 | 98 | case 'pyrightVersion': { 99 | state.settings.pyrightVersion = value; 100 | break; 101 | } 102 | 103 | case 'pythonVersion': { 104 | state.settings.pythonVersion = value; 105 | break; 106 | } 107 | 108 | case 'pythonPlatform': { 109 | state.settings.pythonPlatform = value; 110 | break; 111 | } 112 | 113 | case 'locale': { 114 | state.settings.locale = value; 115 | break; 116 | } 117 | 118 | default: { 119 | if (configSettingsMap.has(key)) { 120 | state.settings.configOverrides[key] = Boolean(value); 121 | } 122 | } 123 | } 124 | }); 125 | 126 | return state; 127 | } 128 | -------------------------------------------------------------------------------- /client/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Pyright Playground", 4 | "version": "1.0.0", 5 | "userInterfaceStyle": "light", 6 | "assetBundlePatterns": ["**/*"], 7 | "web": { 8 | "favicon": "./assets/favicon.png" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erictraut/pyright-playground/e35474deed4ee19fb7ff5bc37e65ac415010c65c/client/assets/favicon.png -------------------------------------------------------------------------------- /client/assets/pyright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erictraut/pyright-playground/e35474deed4ee19fb7ff5bc37e65ac415010c65c/client/assets/pyright.png -------------------------------------------------------------------------------- /client/assets/pyright_bw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erictraut/pyright-playground/e35474deed4ee19fb7ff5bc37e65ac415010c65c/client/assets/pyright_bw.png -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "web": "expo start --web", 8 | "build:web": "expo export:web && shx rm -rf ../server/dist/webapp && shx mkdir -p ../server/dist/webapp && shx cp -r ./web-build/* ../server/dist/webapp/" 9 | }, 10 | "dependencies": { 11 | "@ant-design/icons": "^5.2.6", 12 | "@monaco-editor/react": "^4.6.0", 13 | "expo": "~49.0.15", 14 | "expo-status-bar": "~1.6.0", 15 | "lz-string": "^1.5.0", 16 | "monaco-editor": "^0.44.0", 17 | "react": "18.2.0", 18 | "react-dom": "18.2.0", 19 | "react-native": "0.72.6", 20 | "react-native-popup-menu": "^0.16.1", 21 | "react-native-web": "~0.19.6", 22 | "vscode-languageserver-types": "^3.17.5" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.20.0", 26 | "@expo/webpack-config": "^19.0.0", 27 | "@types/react": "~18.2.14", 28 | "shx": "^0.3.4", 29 | "typescript": "^5.1.3" 30 | }, 31 | "private": true 32 | } 33 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } -------------------------------------------------------------------------------- /client/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 13 | %WEB_TITLE% 14 | 65 | 66 | 67 | 68 | 72 | 108 | 109 |
110 | 111 | 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pyright-playground", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "build": "cd client && npm i && npm run build:web && cd ../server && npm i && npm run build && cd .." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "printWidth": 100, 6 | "endOfLine": "auto", 7 | "overrides": [ 8 | { 9 | "files": ["*.yml", "*.yaml"], 10 | "options": { 11 | "tabWidth": 2 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | ## Building the Server 2 | 3 | > npm run build 4 | 5 | ## Running the Server 6 | 7 | > node index.js 8 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | require('./build/main'); 2 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pyright-playground-server", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "applicationinsights": "^2.9.0", 6 | "cors": "^2.8.5", 7 | "dotenv": "^16.0.3", 8 | "express": "^4.19.2", 9 | "package-json": "^8.1.1", 10 | "uuid": "^9.0.1", 11 | "vscode-jsonrpc": "^8.2.0", 12 | "vscode-languageclient": "^9.0.1", 13 | "vscode-languageserver": "^9.0.1", 14 | "winston": "^3.11.0" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.20.0", 18 | "@types/cors": "^2.8.13", 19 | "@types/express": "^4.17.17", 20 | "@types/uuid": "^9.0.6", 21 | "ts-loader": "^9.4.2", 22 | "typescript": "^5.0.0", 23 | "webpack": "^5.76.0", 24 | "webpack-cli": "^4.10.0" 25 | }, 26 | "scripts": { 27 | "build": "webpack --mode development --progress" 28 | }, 29 | "private": true 30 | } 31 | -------------------------------------------------------------------------------- /server/src/logging.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Provides logging APIs that can output to both the local console 4 | * and app insights in the cloud. 5 | */ 6 | 7 | import * as winston from 'winston'; 8 | 9 | // Create a simple console logger transport. 10 | export const logger = winston.createLogger({ 11 | transports: [new winston.transports.Console()], 12 | }); 13 | -------------------------------------------------------------------------------- /server/src/lspClient.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Acts as a simple language server protocol (LSP) client that exchanges 4 | * information with a language server. 5 | */ 6 | 7 | import { ChildProcess } from 'node:child_process'; 8 | import { 9 | IPCMessageReader, 10 | IPCMessageWriter, 11 | MessageConnection, 12 | NotificationType, 13 | RequestType, 14 | createMessageConnection, 15 | } from 'vscode-jsonrpc/node'; 16 | import { 17 | CompletionItem, 18 | CompletionList, 19 | CompletionParams, 20 | CompletionRequest, 21 | CompletionResolveRequest, 22 | ConfigurationParams, 23 | Diagnostic, 24 | DiagnosticTag, 25 | DidChangeConfigurationParams, 26 | DidChangeTextDocumentParams, 27 | DidOpenTextDocumentParams, 28 | Hover, 29 | HoverParams, 30 | HoverRequest, 31 | InitializeParams, 32 | InitializeRequest, 33 | LogMessageParams, 34 | Position, 35 | PublishDiagnosticsParams, 36 | RenameParams, 37 | RenameRequest, 38 | SignatureHelp, 39 | SignatureHelpParams, 40 | SignatureHelpRequest, 41 | WorkspaceEdit, 42 | } from 'vscode-languageserver'; 43 | import { SessionOptions } from './session'; 44 | import { logger } from './logging'; 45 | 46 | interface DiagnosticRequest { 47 | callback: (diags: Diagnostic[], error?: Error) => void; 48 | } 49 | 50 | const documentUri = 'file:///Untitled.py'; 51 | 52 | export class LspClient { 53 | private _connection: MessageConnection; 54 | private _documentVersion = 1; 55 | private _documentText = ''; 56 | private _documentDiags: PublishDiagnosticsParams | undefined; 57 | private _pendingDiagRequests = new Map(); 58 | 59 | constructor(langServer: ChildProcess) { 60 | langServer.stderr?.on('data', (data) => LspClient._logServerData(data)); 61 | langServer.stdout?.on('data', (data) => LspClient._logServerData(data)); 62 | 63 | this._connection = createMessageConnection( 64 | new IPCMessageReader(langServer), 65 | new IPCMessageWriter(langServer) 66 | ); 67 | 68 | this._connection.listen(); 69 | } 70 | 71 | public async initialize(projectPath: string, sessionOptions?: SessionOptions) { 72 | // Initialize the server. 73 | const init: InitializeParams = { 74 | rootUri: `file://${projectPath}`, 75 | rootPath: projectPath, 76 | processId: 1, 77 | capabilities: { 78 | textDocument: { 79 | publishDiagnostics: { 80 | tagSupport: { 81 | valueSet: [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated], 82 | }, 83 | versionSupport: true, 84 | }, 85 | hover: { 86 | contentFormat: ['markdown', 'plaintext'], 87 | }, 88 | signatureHelp: {}, 89 | }, 90 | }, 91 | }; 92 | 93 | if (sessionOptions?.locale) { 94 | init.locale = sessionOptions.locale; 95 | } 96 | 97 | this._documentText = sessionOptions?.code ?? ''; 98 | 99 | await this._connection.sendRequest(InitializeRequest.type, init); 100 | 101 | // Update the settings. 102 | await this._connection.sendNotification( 103 | new NotificationType('workspace/didChangeConfiguration'), 104 | { 105 | settings: {}, 106 | } 107 | ); 108 | 109 | // Simulate an "open file" event. 110 | await this._connection.sendNotification( 111 | new NotificationType('textDocument/didOpen'), 112 | { 113 | textDocument: { 114 | uri: documentUri, 115 | languageId: 'python', 116 | version: this._documentVersion, 117 | text: this._documentText, 118 | }, 119 | } 120 | ); 121 | 122 | // Receive diagnostics from the language server. 123 | this._connection.onNotification( 124 | new NotificationType('textDocument/publishDiagnostics'), 125 | (diagInfo) => { 126 | const diagVersion = diagInfo.version ?? -1; 127 | 128 | logger.info(`Received diagnostics for version: ${diagVersion}`); 129 | 130 | // Update the cached diagnostics. 131 | if ( 132 | this._documentDiags === undefined || 133 | this._documentDiags.version! < diagVersion 134 | ) { 135 | this._documentDiags = diagInfo; 136 | } 137 | 138 | // Resolve any pending diagnostic requests. 139 | const pendingRequests = this._pendingDiagRequests.get(diagVersion) ?? []; 140 | this._pendingDiagRequests.delete(diagVersion); 141 | 142 | for (const request of pendingRequests) { 143 | request.callback(diagInfo.diagnostics); 144 | } 145 | } 146 | ); 147 | 148 | // Log messages received by the language server for debugging purposes. 149 | this._connection.onNotification( 150 | new NotificationType('window/logMessage'), 151 | (info) => { 152 | logger.info(`Language server log message: ${info.message}`); 153 | } 154 | ); 155 | 156 | // Handle requests for configurations. 157 | this._connection.onRequest( 158 | new RequestType('workspace/configuration'), 159 | (params) => { 160 | logger.info(`Language server config request: ${JSON.stringify(params)}}`); 161 | return []; 162 | } 163 | ); 164 | } 165 | 166 | async getDiagnostics(code: string): Promise { 167 | const codeChanged = this._documentText !== code; 168 | 169 | // If the code hasn't changed since the last time we received 170 | // a code update, return the cached diagnostics. 171 | if (!codeChanged && this._documentDiags) { 172 | return this._documentDiags.diagnostics; 173 | } 174 | 175 | // The diagnostics will come back asynchronously, so 176 | // return a promise. 177 | return new Promise(async (resolve, reject) => { 178 | let documentVersion = this._documentVersion; 179 | 180 | if (codeChanged) { 181 | documentVersion = await this.updateTextDocument(code); 182 | } 183 | 184 | // Queue a request for diagnostics. 185 | let requestList = this._pendingDiagRequests.get(documentVersion); 186 | if (!requestList) { 187 | requestList = []; 188 | this._pendingDiagRequests.set(documentVersion, requestList); 189 | } 190 | 191 | requestList.push({ 192 | callback: (diagnostics, err) => { 193 | if (err) { 194 | reject(err); 195 | return; 196 | } 197 | 198 | logger.info(`Diagnostic callback ${JSON.stringify(diagnostics)}}`); 199 | resolve(diagnostics); 200 | }, 201 | }); 202 | }); 203 | } 204 | 205 | async getHoverInfo(code: string, position: Position): Promise { 206 | let documentVersion = this._documentVersion; 207 | if (this._documentText !== code) { 208 | documentVersion = await this.updateTextDocument(code); 209 | } 210 | 211 | const params: HoverParams = { 212 | textDocument: { 213 | uri: documentUri, 214 | }, 215 | position, 216 | }; 217 | 218 | const result = await this._connection 219 | .sendRequest(HoverRequest.type, params) 220 | .catch((err) => { 221 | // Don't return an error. Just return null (no info). 222 | return null; 223 | }); 224 | 225 | return result; 226 | } 227 | 228 | async getRenameEdits( 229 | code: string, 230 | position: Position, 231 | newName: string 232 | ): Promise { 233 | let documentVersion = this._documentVersion; 234 | if (this._documentText !== code) { 235 | documentVersion = await this.updateTextDocument(code); 236 | } 237 | 238 | const params: RenameParams = { 239 | textDocument: { 240 | uri: documentUri, 241 | }, 242 | position, 243 | newName, 244 | }; 245 | 246 | const result = await this._connection 247 | .sendRequest(RenameRequest.type, params) 248 | .catch((err) => { 249 | // Don't return an error. Just return null (no edits). 250 | return null; 251 | }); 252 | 253 | return result; 254 | } 255 | 256 | async getSignatureHelp(code: string, position: Position): Promise { 257 | let documentVersion = this._documentVersion; 258 | if (this._documentText !== code) { 259 | documentVersion = await this.updateTextDocument(code); 260 | } 261 | 262 | const params: SignatureHelpParams = { 263 | textDocument: { 264 | uri: documentUri, 265 | }, 266 | position, 267 | }; 268 | 269 | const result = await this._connection 270 | .sendRequest(SignatureHelpRequest.type, params) 271 | .catch((err) => { 272 | // Don't return an error. Just return null (no info). 273 | return null; 274 | }); 275 | 276 | return result; 277 | } 278 | 279 | async getCompletion( 280 | code: string, 281 | position: Position 282 | ): Promise { 283 | let documentVersion = this._documentVersion; 284 | if (this._documentText !== code) { 285 | documentVersion = await this.updateTextDocument(code); 286 | } 287 | 288 | const params: CompletionParams = { 289 | textDocument: { 290 | uri: documentUri, 291 | }, 292 | position, 293 | }; 294 | 295 | const result = await this._connection 296 | .sendRequest(CompletionRequest.type, params) 297 | .catch((err) => { 298 | // Don't return an error. Just return null (no info). 299 | return null; 300 | }); 301 | 302 | return result; 303 | } 304 | 305 | async resolveCompletion(completionItem: CompletionItem): Promise { 306 | const result = await this._connection 307 | .sendRequest(CompletionResolveRequest.type, completionItem) 308 | .catch((err) => { 309 | // Don't return an error. Just return null (no info). 310 | return null; 311 | }); 312 | 313 | return result; 314 | } 315 | 316 | // Sends a new version of the text document to the language server. 317 | // It bumps the document version and returns the new version number. 318 | private async updateTextDocument(code: string): Promise { 319 | let documentVersion = ++this._documentVersion; 320 | this._documentText = code; 321 | 322 | logger.info(`Updating text document to version ${documentVersion}`); 323 | 324 | // Send the updated text to the language server. 325 | return this._connection 326 | .sendNotification( 327 | new NotificationType('textDocument/didChange'), 328 | { 329 | textDocument: { 330 | uri: documentUri, 331 | version: documentVersion, 332 | }, 333 | contentChanges: [ 334 | { 335 | text: code, 336 | }, 337 | ], 338 | } 339 | ) 340 | .then(() => { 341 | logger.info(`Successfully sent text document to language server`); 342 | return documentVersion; 343 | }) 344 | .catch((err) => { 345 | logger.error(`Error sending text document to language server: ${err}`); 346 | throw err; 347 | }); 348 | } 349 | 350 | // Cancels all pending requests. 351 | cancelRequests() { 352 | this._pendingDiagRequests.forEach((requestList) => { 353 | requestList.forEach((request) => { 354 | request.callback([], new Error('Request canceled')); 355 | }); 356 | }); 357 | 358 | this._pendingDiagRequests.clear(); 359 | this._documentText = ''; 360 | this._documentDiags = undefined; 361 | this._documentVersion = 1; 362 | } 363 | 364 | private static _logServerData(data: any) { 365 | logger.info( 366 | `Logged from pyright language server: ${ 367 | typeof data === 'string' ? data : data.toString('utf8') 368 | }` 369 | ); 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Main entry point for the app server. 4 | */ 5 | 6 | import * as appInsight from 'applicationinsights'; 7 | import bodyParser from 'body-parser'; 8 | import * as dotenv from 'dotenv'; 9 | import express, { NextFunction, Request, Response } from 'express'; 10 | import * as path from 'path'; 11 | import routes from './routes'; 12 | import { logger } from './logging'; 13 | 14 | try { 15 | // Load environment variables from ".env" file. 16 | dotenv.config(); 17 | 18 | const appInsightsKey = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING; 19 | if (appInsightsKey) { 20 | appInsight 21 | .setup(appInsightsKey) 22 | .setAutoDependencyCorrelation(true) 23 | .setAutoCollectRequests(true) 24 | .setAutoCollectPerformance(true, true) 25 | .setAutoCollectExceptions(true) 26 | .setAutoCollectDependencies(true) 27 | .setAutoCollectConsole(true, true) 28 | .setSendLiveMetrics(false) 29 | .setDistributedTracingMode(appInsight.DistributedTracingModes.AI) 30 | .start(); 31 | } 32 | 33 | startService(); 34 | } catch (err) { 35 | logger.error(`Uncaught exception: ${err}`); 36 | throw err; 37 | } 38 | 39 | function startService() { 40 | const root = './'; 41 | const apiPort = process.env.PORT || 3000; 42 | const app = express(); 43 | 44 | // Middleware to log the time taken by each request 45 | const requestTimeLogger = (req: Request, res: Response, next: NextFunction) => { 46 | const start = Date.now(); 47 | 48 | res.on('finish', () => { 49 | const duration = Date.now() - start; 50 | console.log(`${req.method} ${req.originalUrl} took ${duration}ms`); 51 | }); 52 | 53 | next(); 54 | }; 55 | 56 | // Use the middleware globally. 57 | app.use(requestTimeLogger); 58 | 59 | app.use(bodyParser.json()); 60 | app.use(bodyParser.urlencoded({ extended: true })); 61 | app.use('/api', routes); 62 | 63 | app.use('*', (req, res, next) => { 64 | // Redirect from azurewebsites URL to custom domain. 65 | if (req.hostname.match('pyright-playground.azurewebsites.net')) { 66 | res.redirect(301, 'https://pyright-play.net'); 67 | } else { 68 | next(); 69 | } 70 | }); 71 | 72 | app.use(express.static(path.join(root, 'dist/webapp'))); 73 | 74 | app.get('*', (req, res) => { 75 | res.sendFile('dist/webapp/index.html', { root }); 76 | }); 77 | 78 | app.listen(apiPort, () => { 79 | logger.info(`API running on port ${apiPort}`); 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /server/src/routes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Defines endpoint routes supported by this app server. 4 | */ 5 | 6 | import cors, { CorsOptions } from 'cors'; 7 | import express from 'express'; 8 | import { 9 | getDiagnostics, 10 | createSession, 11 | closeSession, 12 | getHoverInfo, 13 | getStatus, 14 | getSignatureHelp, 15 | getCompletion, 16 | resolveCompletion, 17 | getRenameEdits, 18 | } from './service'; 19 | 20 | const router = express.Router(); 21 | export default router; 22 | 23 | // Configure CORS middleware. 24 | const corsOptions: CorsOptions = { 25 | origin: [ 26 | /http:\/\/localhost\:*/, 27 | 'https://pyright-playground.azurewebsites.net', 28 | 'https://pyright-play.net', 29 | ], 30 | }; 31 | 32 | router.use(cors(corsOptions)); 33 | 34 | router.get('/status', (req, res) => { 35 | getStatus(req, res); 36 | }); 37 | 38 | router.post('/session', (req, res) => { 39 | createSession(req, res); 40 | }); 41 | 42 | router.delete('/session/:sid', (req, res) => { 43 | closeSession(req, res); 44 | }); 45 | 46 | router.post('/session/:sid/diagnostics', (req, res) => { 47 | getDiagnostics(req, res); 48 | }); 49 | 50 | router.post('/session/:sid/hover', (req, res) => { 51 | getHoverInfo(req, res); 52 | }); 53 | 54 | router.post('/session/:sid/rename', (req, res) => { 55 | getRenameEdits(req, res); 56 | }); 57 | 58 | router.post('/session/:sid/signature', (req, res) => { 59 | getSignatureHelp(req, res); 60 | }); 61 | 62 | router.post('/session/:sid/completion', (req, res) => { 63 | getCompletion(req, res); 64 | }); 65 | 66 | router.post('/session/:sid/completionresolve', (req, res) => { 67 | resolveCompletion(req, res); 68 | }); 69 | -------------------------------------------------------------------------------- /server/src/service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Implements primary API endpoints for the seb service. 4 | */ 5 | 6 | import { Request, Response } from 'express'; 7 | import * as SessionManager from './sessionManager'; 8 | import { Session, SessionOptions } from './session'; 9 | import { Position } from 'vscode-languageserver'; 10 | import { logger } from './logging'; 11 | 12 | interface CodeWithOptions { 13 | code: string; 14 | position?: Position; 15 | newName?: string; 16 | } 17 | 18 | // Retrieves the current status of the service including the 19 | // versions of pyright that it supports. 20 | export function getStatus(req: Request, res: Response) { 21 | SessionManager.getPyrightVersions() 22 | .then((pyrightVersions) => { 23 | res.status(200).json({ pyrightVersions }); 24 | }) 25 | .catch((err) => { 26 | logger.error(`getStatus returning a 500: ${err}`); 27 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 28 | }); 29 | } 30 | 31 | // Creates a new language server session and returns its ID. 32 | export function createSession(req: Request, res: Response) { 33 | const sessionOptions = validateSessionOptions(req, res); 34 | if (!sessionOptions) { 35 | return; 36 | } 37 | 38 | SessionManager.createSession(sessionOptions) 39 | .then((sessionId) => { 40 | res.status(200).json({ sessionId }); 41 | }) 42 | .catch((err) => { 43 | logger.error(`createNewSession returning a 500: ${err}`); 44 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 45 | }); 46 | } 47 | 48 | export function closeSession(req: Request, res: Response) { 49 | const session = validateSession(req, res); 50 | if (!session) { 51 | return; 52 | } 53 | 54 | SessionManager.recycleSession(session.id); 55 | res.status(200).json({}); 56 | } 57 | 58 | // Given some Python code and associated options, returns 59 | // a list of diagnostics. 60 | export function getDiagnostics(req: Request, res: Response) { 61 | const session = validateSession(req, res); 62 | const langClient = session?.langClient; 63 | if (!langClient) { 64 | return; 65 | } 66 | 67 | const codeWithOptions = validateCodeWithOptions(req, res); 68 | if (!codeWithOptions) { 69 | return; 70 | } 71 | 72 | langClient 73 | .getDiagnostics(codeWithOptions.code) 74 | .then((diagnostics) => { 75 | res.status(200).json({ diagnostics }); 76 | }) 77 | .catch((err) => { 78 | logger.error(`getDiagnostics returning a 500: ${err}`); 79 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 80 | }); 81 | } 82 | 83 | // Given some Python code and a position within that code, 84 | // returns hover information. 85 | export function getHoverInfo(req: Request, res: Response) { 86 | const session = validateSession(req, res); 87 | const langClient = session?.langClient; 88 | if (!langClient) { 89 | return; 90 | } 91 | 92 | const codeWithOptions = validateCodeWithOptions(req, res, ['position']); 93 | if (!codeWithOptions) { 94 | return; 95 | } 96 | 97 | langClient 98 | .getHoverInfo(codeWithOptions.code, codeWithOptions.position!) 99 | .then((hover) => { 100 | res.status(200).json({ hover }); 101 | }) 102 | .catch((err) => { 103 | logger.error(`getHoverInfo returning a 500: ${err}`); 104 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 105 | }); 106 | } 107 | 108 | // Given some Python code and a position within that code and a new name, 109 | // returns a list of edits to effect a semantic rename. 110 | export function getRenameEdits(req: Request, res: Response) { 111 | const session = validateSession(req, res); 112 | const langClient = session?.langClient; 113 | if (!langClient) { 114 | return; 115 | } 116 | 117 | const codeWithOptions = validateCodeWithOptions(req, res, ['position', 'newName']); 118 | if (!codeWithOptions) { 119 | return; 120 | } 121 | 122 | langClient 123 | .getRenameEdits( 124 | codeWithOptions.code, 125 | codeWithOptions.position!, 126 | codeWithOptions.newName ?? '' 127 | ) 128 | .then((edits) => { 129 | res.status(200).json({ edits }); 130 | }) 131 | .catch((err) => { 132 | logger.error(`getRenameEdits returning a 500: ${err}`); 133 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 134 | }); 135 | } 136 | 137 | export function getSignatureHelp(req: Request, res: Response) { 138 | const session = validateSession(req, res); 139 | const langClient = session?.langClient; 140 | if (!langClient) { 141 | return; 142 | } 143 | 144 | const codeWithOptions = validateCodeWithOptions(req, res, ['position']); 145 | if (!codeWithOptions) { 146 | return; 147 | } 148 | 149 | langClient 150 | .getSignatureHelp(codeWithOptions.code, codeWithOptions.position!) 151 | .then((signatureHelp) => { 152 | res.status(200).json({ signatureHelp }); 153 | }) 154 | .catch((err) => { 155 | logger.error(`getSignatureHelp returning a 500: ${err}`); 156 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 157 | }); 158 | } 159 | 160 | export function getCompletion(req: Request, res: Response) { 161 | const session = validateSession(req, res); 162 | const langClient = session?.langClient; 163 | if (!langClient) { 164 | return; 165 | } 166 | 167 | const codeWithOptions = validateCodeWithOptions(req, res, ['position']); 168 | if (!codeWithOptions) { 169 | return; 170 | } 171 | 172 | langClient 173 | .getCompletion(codeWithOptions.code, codeWithOptions.position!) 174 | .then((completionList) => { 175 | res.status(200).json({ completionList }); 176 | }) 177 | .catch((err) => { 178 | logger.error(`getCompletion returning a 500: ${err}`); 179 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 180 | }); 181 | } 182 | 183 | export function resolveCompletion(req: Request, res: Response) { 184 | const session = validateSession(req, res); 185 | const langClient = session?.langClient; 186 | if (!langClient) { 187 | return; 188 | } 189 | 190 | if (!req.body || typeof req.body !== 'object') { 191 | res.status(400).json({ message: 'Invalid request body' }); 192 | return; 193 | } 194 | 195 | const completionItem = req.body.completionItem; 196 | if (typeof completionItem !== 'object') { 197 | res.status(400).json({ message: 'Invalid completionItem' }); 198 | return; 199 | } 200 | 201 | langClient 202 | .resolveCompletion(completionItem) 203 | .then((completionItem) => { 204 | res.status(200).json({ completionItem }); 205 | }) 206 | .catch((err) => { 207 | logger.error(`resolveCompletion returning a 500: ${err}`); 208 | res.status(500).json({ message: err || 'An unexpected error occurred' }); 209 | }); 210 | } 211 | 212 | function validateSessionOptions(req: Request, res: Response): SessionOptions | undefined { 213 | if (!req.body || typeof req.body !== 'object') { 214 | res.status(400).json({ message: 'Invalid request body' }); 215 | return undefined; 216 | } 217 | 218 | const pyrightVersion = req.body.pyrightVersion; 219 | if (pyrightVersion !== undefined) { 220 | if (typeof pyrightVersion !== 'string' || !pyrightVersion.match(/1.[0-9]+.[0-9]+/)) { 221 | res.status(400).json({ message: 'Invalid pyrightVersion' }); 222 | return undefined; 223 | } 224 | } 225 | 226 | const pythonVersion = req.body.pythonVersion; 227 | if (pythonVersion !== undefined) { 228 | if (typeof pythonVersion !== 'string' || !pythonVersion.match(/3.[0-9]+/)) { 229 | res.status(400).json({ message: 'Invalid pythonVersion' }); 230 | return undefined; 231 | } 232 | } 233 | 234 | const pythonPlatform = req.body.pythonPlatform; 235 | if (pythonPlatform !== undefined) { 236 | if (typeof pythonPlatform !== 'string') { 237 | res.status(400).json({ message: 'Invalid pythonPlatform' }); 238 | return undefined; 239 | } 240 | } 241 | 242 | const locale = req.body.locale; 243 | if (locale !== undefined) { 244 | if (typeof locale !== 'string') { 245 | res.status(400).json({ message: 'Invalid locale' }); 246 | return undefined; 247 | } 248 | } 249 | 250 | const typeCheckingMode = req.body.typeCheckingMode; 251 | if (typeCheckingMode !== undefined) { 252 | if (typeCheckingMode !== 'strict') { 253 | res.status(400).json({ message: 'Invalid typeCheckingMode' }); 254 | return undefined; 255 | } 256 | } 257 | 258 | const code = req.body.code; 259 | if (code !== undefined) { 260 | if (typeof code !== 'string') { 261 | res.status(400).json({ message: 'Invalid code' }); 262 | return undefined; 263 | } 264 | } 265 | 266 | const configOverrides: { [name: string]: boolean } = {}; 267 | if (req.body.configOverrides !== undefined) { 268 | if (typeof req.body.configOverrides !== 'object') { 269 | res.status(400).json({ message: 'Invalid configOverrides' }); 270 | return undefined; 271 | } 272 | 273 | for (const key of Object.keys(req.body.configOverrides)) { 274 | const value = req.body.configOverrides[key]; 275 | if (typeof value !== 'boolean') { 276 | res.status(400).json({ message: `Invalid value for configOverrides key ${key}` }); 277 | return undefined; 278 | } 279 | 280 | configOverrides[key] = value; 281 | } 282 | } 283 | 284 | return { 285 | pyrightVersion, 286 | pythonVersion, 287 | pythonPlatform, 288 | typeCheckingMode, 289 | configOverrides, 290 | locale, 291 | code, 292 | }; 293 | } 294 | 295 | function validateCodeWithOptions( 296 | req: Request, 297 | res: Response, 298 | options?: string[] 299 | ): CodeWithOptions | undefined { 300 | let reportedError = false; 301 | 302 | if (!req.body || typeof req.body !== 'object') { 303 | res.status(400).json({ message: 'Invalid request body' }); 304 | return undefined; 305 | } 306 | 307 | const code = req.body.code; 308 | if (typeof code !== 'string') { 309 | res.status(400).json({ message: 'Invalid code' }); 310 | return undefined; 311 | } 312 | 313 | const response: CodeWithOptions = { code }; 314 | 315 | options?.forEach((option) => { 316 | if (option === 'position') { 317 | const position = req.body.position; 318 | if ( 319 | typeof position !== 'object' || 320 | typeof position.line !== 'number' || 321 | typeof position.character !== 'number' 322 | ) { 323 | res.status(400).json({ message: 'Invalid position' }); 324 | reportedError = true; 325 | } else { 326 | response.position = { 327 | line: position.line, 328 | character: position.character, 329 | }; 330 | } 331 | } else if (option === 'newName') { 332 | const newName = req.body.newName; 333 | if (typeof newName !== 'string') { 334 | res.status(400).json({ message: 'Invalid newName' }); 335 | reportedError = true; 336 | } else { 337 | response.newName = newName; 338 | } 339 | } 340 | }); 341 | 342 | return reportedError ? undefined : response; 343 | } 344 | 345 | function validateSession(req: Request, res: Response): Session | undefined { 346 | const sessionId = req.params.sid; 347 | if (!sessionId || typeof sessionId !== 'string') { 348 | res.status(400).json({ message: 'Invalid session ID' }); 349 | return undefined; 350 | } 351 | 352 | const session = SessionManager.getSessionById(sessionId); 353 | if (!session?.langClient) { 354 | res.status(400).json({ message: 'Unknown session ID' }); 355 | return undefined; 356 | } 357 | 358 | return session; 359 | } 360 | -------------------------------------------------------------------------------- /server/src/session.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Represents a single session of the playground. A session represents 4 | * an instantiated language server. Sessions persists across API calls for 5 | * performance reasons. 6 | */ 7 | 8 | import { ChildProcess } from 'node:child_process'; 9 | import { LspClient } from './lspClient'; 10 | 11 | export type SessionId = string; 12 | 13 | export interface SessionOptions { 14 | pythonVersion?: string; 15 | pythonPlatform?: string; 16 | pyrightVersion?: string; 17 | typeCheckingMode?: string; 18 | configOverrides?: { [name: string]: boolean }; 19 | locale?: string; 20 | code?: string; 21 | } 22 | 23 | export interface Session { 24 | // A unique ID for this session. 25 | readonly id: SessionId; 26 | 27 | // Path to temp directory that contains the "project" for this session. 28 | tempDirPath: string; 29 | 30 | // Child process running the language server for this session. 31 | langServerProcess?: ChildProcess; 32 | 33 | // Proxy language client that interacts with the server. 34 | langClient?: LspClient; 35 | 36 | // Timestamp of last request to the session. 37 | lastAccessTime: number; 38 | 39 | // Options associated with the session. 40 | options?: SessionOptions; 41 | } 42 | -------------------------------------------------------------------------------- /server/src/sessionManager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Eric Traut 3 | * Manages a collection of playground sessions. It tracks the set of active 4 | * sessions and manages their lifetimes. 5 | */ 6 | 7 | import * as fs from 'fs'; 8 | import { exec, fork } from 'node:child_process'; 9 | import * as os from 'os'; 10 | import packageJson from 'package-json'; 11 | import * as path from 'path'; 12 | import { v4 as uuid } from 'uuid'; 13 | import { LspClient } from './lspClient'; 14 | import { Session, SessionId, SessionOptions } from './session'; 15 | import { logger } from './logging'; 16 | 17 | export interface InstallPyrightInfo { 18 | pyrightVersion: string; 19 | localDirectory: string; 20 | } 21 | 22 | // Map of active sessions indexed by ID 23 | const activeSessions = new Map(); 24 | 25 | // List of inactive sessions that can be reused. 26 | const inactiveSessions: Session[] = []; 27 | 28 | // Maximum time a session can be idle before it is closed. 29 | const maxSessionLifetime = 1 * 60 * 1000; // 1 minute 30 | 31 | // Maximum number of pyright versions to return to the caller. 32 | const maxPyrightVersionCount = 50; 33 | 34 | // If the caller doesn't specify the pythonVersion or pythonPlatform, 35 | // default to these. Otherwise the language server will pick these 36 | // based on whatever version of Python happens to be installed in 37 | // the container it's running in. 38 | const defaultPythonVersion = '3.13'; 39 | const defaultPythonPlatform = 'All'; 40 | 41 | // Active lifetime timer for harvesting old sessions. 42 | let lifetimeTimer: NodeJS.Timeout | undefined; 43 | 44 | // Cached "latest" version of pyright. 45 | const timeBetweenVersionRequestsInMs = 60 * 60 * 1000; // 1 hour 46 | let lastVersionRequestTime = 0; 47 | let lastVersion = ''; 48 | 49 | const maxInactiveSessionCount = 64; 50 | 51 | export function getSessionById(id: SessionId) { 52 | const session = activeSessions.get(id); 53 | 54 | if (session) { 55 | session.lastAccessTime = Date.now(); 56 | } 57 | 58 | return session; 59 | } 60 | 61 | // Allocate a new session and return its ID. 62 | export async function createSession( 63 | sessionOptions: SessionOptions | undefined 64 | ): Promise { 65 | scheduleSessionLifetimeTimer(); 66 | 67 | // See if there are any inactive sessions that can be reused. 68 | const inactiveSession = getCompatibleInactiveSession(sessionOptions); 69 | if (inactiveSession) { 70 | return restartSession(inactiveSession, sessionOptions); 71 | } 72 | 73 | return installPyright(sessionOptions?.pyrightVersion).then((info) => { 74 | return startSession(info.localDirectory, sessionOptions); 75 | }); 76 | } 77 | 78 | // Places an existing session into an inactive pool that can be used 79 | // for future requests. 80 | export function recycleSession(sessionId: SessionId) { 81 | const session = activeSessions.get(sessionId); 82 | if (!session) { 83 | return; 84 | } 85 | 86 | session.langClient?.cancelRequests(); 87 | 88 | activeSessions.delete(sessionId); 89 | inactiveSessions.push(session); 90 | 91 | if (inactiveSessions.length > maxInactiveSessionCount) { 92 | const session = inactiveSessions.shift(); 93 | if (session) { 94 | terminateSession(session); 95 | } 96 | } 97 | 98 | logger.info(`Recycling session (currently ${inactiveSessions.length} in inactive queue)`); 99 | } 100 | 101 | export async function getPyrightVersions(): Promise { 102 | return packageJson('pyright', { allVersions: true, fullMetadata: false }) 103 | .then((response) => { 104 | let versions = Object.keys(response.versions); 105 | 106 | // Filter out the really old versions (1.0.x). 107 | versions = versions.filter((version) => !version.startsWith('1.0.')); 108 | 109 | // Return the latest version first. 110 | versions = versions.reverse(); 111 | 112 | // Limit the number of versions returned. 113 | versions = versions.slice(0, maxPyrightVersionCount); 114 | 115 | return versions; 116 | }) 117 | .catch((err) => { 118 | throw new Error(`Failed to get versions of pyright: ${err}`); 119 | }); 120 | } 121 | 122 | export async function getPyrightLatestVersion(): Promise { 123 | const timeSinceLastRequest = Date.now() - lastVersionRequestTime; 124 | 125 | if (timeSinceLastRequest < timeBetweenVersionRequestsInMs) { 126 | logger.info(`Returning cached latest pyright version: ${lastVersion}`); 127 | return lastVersion; 128 | } 129 | 130 | return packageJson('pyright') 131 | .then((response) => { 132 | if (typeof response.version === 'string') { 133 | logger.info(`Received latest pyright version from npm index: ${response.version}`); 134 | 135 | lastVersionRequestTime = Date.now(); 136 | 137 | if (lastVersion !== response.version) { 138 | lastVersion = response.version; 139 | 140 | // We need to terminate all inactive sessions because an empty 141 | // version string in the session options changes meaning when 142 | // the version of pyright changes. 143 | terminateInactiveSessions(); 144 | } 145 | 146 | return response.version; 147 | } 148 | 149 | throw new Error(`Received unexpected latest version for pyright`); 150 | }) 151 | .catch((err) => { 152 | throw new Error(`Failed to get latest version of pyright: ${err}`); 153 | }); 154 | } 155 | 156 | function startSession(binaryDirPath: string, sessionOptions?: SessionOptions): Promise { 157 | return new Promise((resolve, reject) => { 158 | // Launch a new instance of the language server in another process. 159 | logger.info(`Spawning new pyright language server from ${binaryDirPath}`); 160 | const binaryPath = path.join( 161 | process.cwd(), 162 | binaryDirPath, 163 | './node_modules/pyright/langserver.index.js' 164 | ); 165 | 166 | // Create a temp directory where we can store a synthesized config file. 167 | const tempDirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pyright_playground')); 168 | 169 | // Synthesize a "pyrightconfig.json" file from the session options and write 170 | // it to the temp directory so the language server can find it. 171 | synthesizePyrightConfigFile(tempDirPath, sessionOptions); 172 | 173 | // Synthesize an empty venv directory so that pyright doesn't try to 174 | // resolve imports using the default Python environment installed on 175 | // the server's docker container. 176 | synthesizeVenvDirectory(tempDirPath); 177 | 178 | // Set the environment variable for the locale. Older versions 179 | // of pyright don't handle the local passed via the LSP initialize 180 | // request. 181 | const env = { ...process.env }; 182 | if (sessionOptions?.locale) { 183 | env.LC_ALL = sessionOptions.locale; 184 | } 185 | 186 | const langServerProcess = fork( 187 | binaryPath, 188 | ['--node-ipc', `--clientProcessId=${process.pid.toString()}`], 189 | { 190 | cwd: tempDirPath, 191 | silent: true, 192 | env, 193 | } 194 | ); 195 | 196 | // Create a new UUID for a session ID. 197 | const sessionId = uuid(); 198 | 199 | // Create a new session object. 200 | const session: Session = { 201 | id: sessionId, 202 | lastAccessTime: Date.now(), 203 | tempDirPath, 204 | options: sessionOptions, 205 | }; 206 | 207 | // Start tracking the session. 208 | activeSessions.set(sessionId, session); 209 | 210 | langServerProcess.on('spawn', () => { 211 | logger.info(`Pyright language server started`); 212 | session.langServerProcess = langServerProcess; 213 | session.langClient = new LspClient(langServerProcess); 214 | 215 | session.langClient 216 | .initialize(tempDirPath, sessionOptions) 217 | .then(() => { 218 | if (sessionOptions?.code !== undefined) { 219 | if (session.langClient) { 220 | // Warm up the service by sending it an empty file. 221 | logger.info('Sending initial code to warm up service'); 222 | 223 | session.langClient 224 | .getDiagnostics(sessionOptions.code) 225 | .then(() => { 226 | // Throw away results. 227 | logger.info('Received diagnostics from warm up'); 228 | }) 229 | .catch((err) => { 230 | // Throw away error; 231 | }); 232 | } 233 | } 234 | 235 | resolve(sessionId); 236 | }) 237 | .catch((err) => { 238 | reject(`Failed to start pyright language server connection`); 239 | closeSession(sessionId); 240 | }); 241 | }); 242 | 243 | langServerProcess.on('error', (err) => { 244 | // Errors can be reported for a variety of reasons even after 245 | // the language server has been started. 246 | if (!session.langServerProcess) { 247 | logger.error(`Pyright language server failed to start: ${err.message}`); 248 | reject(`Failed to spawn pyright language server instance`); 249 | } 250 | 251 | closeSession(sessionId); 252 | }); 253 | 254 | langServerProcess.on('exit', (code) => { 255 | logger.info(`Pyright language server exited with code ${code}`); 256 | closeSession(sessionId); 257 | }); 258 | 259 | langServerProcess.on('close', (code) => { 260 | logger.info(`Pyright language server closed with code ${code}`); 261 | closeSession(sessionId); 262 | }); 263 | }); 264 | } 265 | 266 | function restartSession(session: Session, sessionOptions?: SessionOptions): SessionId { 267 | logger.info(`Restarting inactive session ${session.id}`); 268 | 269 | session.lastAccessTime = Date.now(); 270 | session.options = sessionOptions; 271 | 272 | // Start tracking the session. 273 | activeSessions.set(session.id, session); 274 | 275 | if (session.langClient && sessionOptions?.code) { 276 | // Send the initial code to warm up the service. 277 | session.langClient.getDiagnostics(sessionOptions.code).catch((err) => { 278 | // Throw away error; 279 | }); 280 | } 281 | 282 | return session.id; 283 | } 284 | 285 | // Attempts to close the session and cleans up its resources. It 286 | // silently fails if it cannot. 287 | function closeSession(sessionId: SessionId) { 288 | const session = activeSessions.get(sessionId); 289 | if (!session) { 290 | return; 291 | } 292 | 293 | session.langClient?.cancelRequests(); 294 | 295 | activeSessions.delete(sessionId); 296 | 297 | terminateSession(session); 298 | } 299 | 300 | function terminateInactiveSessions() { 301 | // Pop all inactive sessions and terminate them. 302 | while (true) { 303 | const session = inactiveSessions.pop(); 304 | if (!session) { 305 | break; 306 | } 307 | 308 | terminateSession(session); 309 | } 310 | } 311 | 312 | function terminateSession(session: Session) { 313 | // If the process exists, attempt to kill it. 314 | if (session.langServerProcess) { 315 | session.langServerProcess.kill(); 316 | } 317 | 318 | session.langServerProcess = undefined; 319 | // Dispose of the temporary directory. 320 | try { 321 | fs.rmSync(session.tempDirPath, { recursive: true }); 322 | } catch (e) { 323 | // Ignore error. 324 | } 325 | } 326 | 327 | function getCompatibleInactiveSession(sessionOptions?: SessionOptions): Session | undefined { 328 | logger.info(`Looking for compatible inactive session`); 329 | 330 | const sessionIndex = inactiveSessions.findIndex((session) => { 331 | if ( 332 | sessionOptions?.pythonVersion !== session.options?.pythonVersion || 333 | sessionOptions?.pythonPlatform !== session.options?.pythonPlatform || 334 | sessionOptions?.pyrightVersion !== session.options?.pyrightVersion || 335 | sessionOptions?.locale !== session.options?.locale || 336 | sessionOptions?.typeCheckingMode !== session.options?.typeCheckingMode 337 | ) { 338 | return false; 339 | } 340 | 341 | const requestedOverrides = sessionOptions?.configOverrides || {}; 342 | const existingOverrides = session.options?.configOverrides || {}; 343 | 344 | if (requestedOverrides.length !== existingOverrides.length) { 345 | return false; 346 | } 347 | 348 | for (const key of Object.keys(requestedOverrides)) { 349 | if (requestedOverrides[key] !== existingOverrides[key]) { 350 | return false; 351 | } 352 | } 353 | 354 | return true; 355 | }); 356 | 357 | if (sessionIndex < 0) { 358 | return undefined; 359 | } 360 | 361 | logger.info(`Found compatible inactive session`); 362 | return inactiveSessions.splice(sessionIndex, 1)[0]; 363 | } 364 | 365 | async function installPyright(requestedVersion: string | undefined): Promise { 366 | logger.info(`Pyright version ${requestedVersion || 'latest'} requested`); 367 | 368 | let version: string; 369 | if (requestedVersion) { 370 | version = requestedVersion; 371 | } else { 372 | version = await getPyrightLatestVersion(); 373 | } 374 | 375 | return new Promise((resolve, reject) => { 376 | const dirName = `./pyright_local/${version}`; 377 | 378 | if (fs.existsSync(dirName)) { 379 | logger.info(`Pyright version ${version} already installed`); 380 | resolve({ pyrightVersion: version, localDirectory: dirName }); 381 | return; 382 | } 383 | 384 | logger.info(`Attempting to install pyright version ${version}`); 385 | exec( 386 | `mkdir -p ${dirName}/node_modules && cd ${dirName} && npm install pyright@${version}`, 387 | (err) => { 388 | if (err) { 389 | logger.error(`Failed to install pyright ${version}`); 390 | reject(`Failed to install pyright@${version}`); 391 | return; 392 | } 393 | 394 | logger.info(`Install of pyright ${version} succeeded`); 395 | 396 | resolve({ pyrightVersion: version, localDirectory: dirName }); 397 | } 398 | ); 399 | }); 400 | } 401 | 402 | function synthesizeVenvDirectory(tempDirPath: string) { 403 | const venvPath = path.join(tempDirPath, 'venv', 'lib', 'site-packages'); 404 | fs.mkdirSync(venvPath, { recursive: true }); 405 | } 406 | 407 | function synthesizePyrightConfigFile(tempDirPath: string, sessionOptions?: SessionOptions) { 408 | const configFilePath = path.join(tempDirPath, 'pyrightconfig.json'); 409 | const config: any = {}; 410 | 411 | if (sessionOptions?.pythonVersion) { 412 | config.pythonVersion = sessionOptions.pythonVersion; 413 | } else { 414 | config.pythonVersion = defaultPythonVersion; 415 | } 416 | 417 | if (sessionOptions?.pythonPlatform) { 418 | config.pythonPlatform = sessionOptions.pythonPlatform; 419 | } else { 420 | config.pythonPlatform = defaultPythonPlatform; 421 | } 422 | 423 | if (sessionOptions?.typeCheckingMode === 'strict') { 424 | config.typeCheckingMode = 'strict'; 425 | } 426 | 427 | // Set the venvPath to a synthesized venv to prevent pyright from 428 | // trying to resolve imports using the default Python environment 429 | // installed on the server's docker container. 430 | config.venvPath = '.'; 431 | config.venv = 'venv'; 432 | 433 | // Indicate that we don't want to resolve native libraries. This is 434 | // expensive, and we know there will be no native libraries in the 435 | // playground. 436 | config.skipNativeLibraries = true; 437 | 438 | if (sessionOptions?.configOverrides) { 439 | Object.keys(sessionOptions.configOverrides).forEach((key) => { 440 | config[key] = sessionOptions.configOverrides![key]; 441 | }); 442 | } 443 | 444 | const configJson = JSON.stringify(config); 445 | fs.writeFileSync(configFilePath, configJson); 446 | } 447 | 448 | // If there is no session lifetime timer, schedule one. 449 | function scheduleSessionLifetimeTimer() { 450 | if (lifetimeTimer) { 451 | return; 452 | } 453 | 454 | const lifetimeTimerFrequency = 1 * 60 * 1000; // 1 minute 455 | 456 | lifetimeTimer = setTimeout(() => { 457 | lifetimeTimer = undefined; 458 | 459 | const curTime = Date.now(); 460 | 461 | activeSessions.forEach((session, sessionId) => { 462 | if (curTime - session.lastAccessTime > maxSessionLifetime) { 463 | logger.info(`Session ${sessionId} timed out; recycling`); 464 | recycleSession(sessionId); 465 | activeSessions.delete(sessionId); 466 | } 467 | }); 468 | 469 | if (activeSessions.size === 0) { 470 | scheduleSessionLifetimeTimer(); 471 | } 472 | }, lifetimeTimerFrequency); 473 | } 474 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "lib": [ 6 | "DOM", 7 | "ESNext" 8 | ], 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "target": "ESNext", 13 | "sourceMap": true, 14 | "strict": true 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "babel.config.js", 19 | "jest.config.js" 20 | ] 21 | } -------------------------------------------------------------------------------- /server/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const outPath = path.resolve(__dirname, 'build'); 4 | 5 | module.exports = (_, { mode }) => { 6 | return { 7 | context: __dirname, 8 | entry: './src/main.ts', 9 | target: 'node', 10 | output: { 11 | filename: '[name].js', 12 | path: outPath, 13 | clean: true, 14 | }, 15 | devtool: mode === 'development' ? 'source-map' : 'nosources-source-map', 16 | resolve: { 17 | extensions: ['.ts', '.js'], 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.ts$/, 23 | loader: 'ts-loader', 24 | options: { 25 | configFile: 'tsconfig.json', 26 | }, 27 | }, 28 | ], 29 | }, 30 | }; 31 | }; 32 | --------------------------------------------------------------------------------