[waspls] diagnostics for external imports and goto definition (#1268)

This commit is contained in:
Craig McIlwrath 2023-06-22 15:37:07 -04:00 committed by GitHub
parent 066b832127
commit 7c0d13d242
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 10048 additions and 229 deletions

View File

@ -107,9 +107,9 @@ jobs:
if: matrix.os == 'ubuntu-20.04'
run: ./run ormolu:check
- name: Compile deploy TS package and move it into the Cabal data dir
- name: Compile deploy TS packages and move it into the Cabal data dir
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'macos-latest'
run: ./tools/install_deploy_package_to_data_dir.sh
run: ./tools/install_packages_to_data_dir.sh
- name: Build external dependencies
run: cabal build --enable-tests --enable-benchmarks --only-dependencies

View File

@ -205,9 +205,13 @@ alias wrun="/home/martin/git/wasp-lang/wasp/waspc/run"
```
### Typescript packages
Wasp bundles some TypeScript packages into the installation artifact (eg: deployment scripts), which end up in the installed version's `waspc_datadir`. To do so in CI, it runs `./tools/install_deploy_package_to_data_dir.sh`.
Wasp bundles some TypeScript packages into the installation artifact (eg: deployment scripts), which end up in the installed version's `waspc_datadir`. To do so in CI, it runs `./tools/install_packages_to_data_dir.sh`.
During normal local development you can treat `packages/deploy` as a regular TS project and develop against it in a standalone manner. However, if you want to test it as part of the Wasp CLI, you can make use of this same script locally. Just manually invoke it before you run something like `cabal run wasp-cli deploy fly ...` in a wasp project so the local data directory is up to date.
During normal local development you can treat the packages in `packages/` as
regular npm projects. See `packages/README.md` for specific information as to
how these projectss are expected to be set up. However, if you want to test it as part of the Wasp CLI, you can make use of this same script locally. Just manually invoke it before you run something like `cabal run wasp-cli deploy fly ...` in a wasp project so the local data directory is up to date.
Note that you can not test these packages as part of `waspc` with `cabal install`: cabal does not copy `packages` along with the rest of the data directory due to a limitation in how you tell cabal which data files to include.
## Tests
For tests we are using [**Tasty**](https://github.com/UnkindPartition/tasty) testing framework. Tasty let's us combine different types of tests into a single test suite.

View File

@ -26,10 +26,10 @@ export const createTask: CreateTask<Pick<Task, 'description'>> = async (
},
},
})
console.log(
'New task created! Btw, current value of someResource is: ' +
getSomeResource()
getSomeResource()
)
return newTask

27
waspc/packages/README.md Normal file
View File

@ -0,0 +1,27 @@
# Testing Packages Locally
Run `tools/install_packages_to_data_dir.sh` to compile the packages and copy
them into `data/`. Then you can use `cabal run` as normal, or you can
`cabal install` and then use `wasp-cli`.
# Adding a New Package
Create a directory in this folder to contain the new package. It should have a
`build` script inside `package.json` as well as a `start` script that calls the
compiled code.
Then, in `data-files` inside `waspc.cabal`, add these files:
```
packages/<package-name>/package.json
packages/<package-name>/package-lock.json
packages/<package-name>/dist/**/*.js
```
The last line assumes the project is compiled to JavaScript files inside the
`dist` directory. You should adjust that if needed.
# CI Builds/Release
The CI workflow runs the package install script, and `tools/make_binary_package.sh`
takes care of copying data files into the release archive.

View File

@ -7,7 +7,8 @@
"bin": "dist/index.js",
"type": "module",
"scripts": {
"build": "npx tsc"
"build": "npx tsc",
"start": "node ./dist/index.js"
},
"dependencies": {
"commander": "^9.4.1",

2
waspc/packages/ts-inspect/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -0,0 +1,26 @@
NOTE: `typescript` is purposefully a normal dependency instead of a dev
dependency.
Run the program `node ./dist/index.js` and pass a list of export requests over
stdin:
```json
[
{ "filenames": ["./src/exports.ts"] },
{
"tsconfig": "~/dev/wasp-todoapp/src/client/tsconfig.json",
"filenames": ["~/dev/wasp-todoapp/src/client/MainPage.tsx"]
}
]
```
It will respond with an object mapping filenames to exports, something like:
```json
{
"./src/exports.ts": [
{ "type": "named", "name": "getExportsOfFiles" },
{ "type": "default" }
]
}
```

View File

@ -0,0 +1,83 @@
module.exports = {
"env": {
"es2020": true,
"node": true
},
"root": true,
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"eol-last": [
"error",
"always"
],
"no-multiple-empty-lines": [
"error",
{
"max": 2,
"maxEOF": 1
}
],
"comma-spacing": [
"error",
{ "before": false, "after": true }
],
"space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}],
"comma-dangle": [
"error",
"always-multiline"
],
"object-curly-spacing": [
"error",
"always"
],
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "function", "next": "function" },
{ "blankLine": "always", "prev": "function", "next": "export" },
{ "blankLine": "always", "prev": "export", "next": "function" },
{ "blankLine": "always", "prev": "export", "next": "export" }
],
"no-duplicate-imports": "error",
"@typescript-eslint/semi": [
"error",
"always"
],
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
}
}
],
"@typescript-eslint/explicit-module-boundary-types": "error"
}
}

View File

@ -0,0 +1,6 @@
export default {
transform: { '^.+\\.ts?$': 'ts-jest' },
testEnvironment: 'node',
testRegex: '/test/.*\\.test\\.ts$',
moduleFileExtensions: ['ts', 'js'],
}

8236
waspc/packages/ts-inspect/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"author": "Wasp Team",
"license": "MIT",
"name": "wasp-ts-inspect",
"version": "0.0.1",
"main": "dist/index.js",
"bin": "dist/index.js",
"type": "module",
"scripts": {
"build": "npx tsc",
"start": "node ./dist/index.js",
"test": "jest"
},
"dependencies": {
"json5": "^2.2.3",
"typescript": "^5.1.3",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/jest": "^29.5.2",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.48.0",
"@typescript-eslint/parser": "^5.48.0",
"eslint": "^8.31.0",
"jest": "^29.5.0",
"ts-jest": "^29.1.0"
}
}

View File

@ -0,0 +1,101 @@
import ts from 'typescript';
import * as fs from 'fs/promises';
import * as path from 'path';
import JSON5 from 'json5';
import { z } from 'zod';
export const ExportRequest = z.object({
tsconfig: z.string().optional(),
filenames: z.array(z.string())
});
export const ExportRequests = z.array(ExportRequest);
export type ExportRequest = z.infer<typeof ExportRequest>;
export type Export
= { type: 'default' } & Range
| { type: 'named', name: string } & Range
export type Range = { range?: { start: Location, end: Location } }
export type Location = { line: number, column: number }
export async function getExportsOfFiles(request: ExportRequest): Promise<{ [file: string]: Export[] }> {
let compilerOptions: ts.CompilerOptions = {};
// If a tsconfig is given, load the configuration.
if (request.tsconfig) {
const configJson = JSON5.parse(await fs.readFile(request.tsconfig, 'utf8'));
const basePath = path.dirname(request.tsconfig)
const { options, errors } = ts.convertCompilerOptionsFromJson(
configJson.compilerOptions, basePath, request.tsconfig
);
if (errors && errors.length) {
throw errors;
}
compilerOptions = options;
}
const exportsMap: { [file: string]: Export[] } = {};
// Initialize the TS compiler.
const program = ts.createProgram(request.filenames, compilerOptions);
const checker = program.getTypeChecker();
// Loop through each given file and try to get its exports.
for (let filename of request.filenames) {
try {
exportsMap[filename] = getExportsForFile(program, checker, filename);
} catch (err) {
console.error(err);
exportsMap[filename] = [];
}
}
return exportsMap;
}
function getExportsForFile(program: ts.Program, checker: ts.TypeChecker, filename: string): Export[] {
const source = program.getSourceFile(filename);
if (!source) {
throw new Error(`Error getting source for ${filename}`);
}
const moduleSymbol = checker.getSymbolAtLocation(source);
if (!moduleSymbol) {
// This is caused by errors within the TS file, so we say there are no exports.
return [];
}
const exports = checker.getExportsOfModule(moduleSymbol);
return exports.map(exp => getExportForExportSymbol(program, checker, exp));
}
function getExportForExportSymbol(program: ts.Program, checker: ts.TypeChecker, exp: ts.Symbol): Export {
let range = undefined;
if (exp.valueDeclaration) {
// NOTE: This isn't a very robust way of getting the location: it will always
// point to the line that has `export`, rather than the line where the exported
// symbol is defined.
const startOffset = exp.valueDeclaration.getStart();
const startPos = ts.getLineAndCharacterOfPosition(
exp.valueDeclaration.getSourceFile(), startOffset
);
const endOffset = exp.valueDeclaration.getEnd();
const endPos = ts.getLineAndCharacterOfPosition(
exp.valueDeclaration.getSourceFile(), endOffset
)
range = {
start: { line: startPos.line, column: startPos.character },
end: { line: endPos.line, column: endPos.character }
};
}
// Convert export to the output format.
const exportName = exp.getName();
if (exportName === 'default') {
return { type: 'default', range };
} else {
return { type: 'named', name: exportName, range };
}
}

View File

@ -0,0 +1,28 @@
import { ExportRequests, getExportsOfFiles } from "./exports.js";
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let chunks = '';
process.stdin.on('data', (data) => {
chunks += data;
});
process.stdin.on('end', () => resolve(chunks));
process.stdin.on('close', () => resolve(chunks));
process.stdin.on('error', (err) => reject(err));
});
}
async function main() {
const inputStr = await readStdin();
const input = JSON.parse(inputStr);
const requests = ExportRequests.parse(input);
let exports = {};
for (let request of requests) {
const newExports = await getExportsOfFiles(request);
exports = { ...exports, ...newExports };
}
console.log(JSON.stringify(exports));
}
main().catch((err) => { console.error(err); process.exit(1); });

View File

@ -0,0 +1,3 @@
export default function add(x: number, y: number): number {
return x + y;
}

View File

@ -0,0 +1,15 @@
export default function isEvenOrOdd(n: number): boolean {
return isEven(n) || isOdd(n);
}
export function isEven(n: number): boolean {
if (n < 0) return isEven(-n);
if (n == 0) return true;
return isOdd(n - 1);
}
export function isOdd(n: number): boolean {
if (n < 0) return isOdd(-n);
if (n == 1) return true;
return isEven(n - 1);
}

View File

@ -0,0 +1,3 @@
export const isEven = (x: number) => {
return (x % 2) === 0;
}

View File

@ -0,0 +1,12 @@
function add(x: number, y: number): number {
return x + y;
}
function sub(x: number, y: number): number {
return x - y;
}
export {
add,
sub
};

View File

@ -0,0 +1,3 @@
{
}

View File

@ -0,0 +1,100 @@
import * as path from 'path';
import { getExportsOfFiles } from "../src/exports";
/**
* Get an absolute path to a test file
* @param filename Name of test file inside __dirname/exportTests directory
*/
function testFile(filename: string): string {
return path.join(__dirname, 'exportTests', filename);
}
const testFiles = {
emptyFile: testFile('empty.ts'),
addFile: testFile('add.ts'),
complexFile: testFile('complex.ts'),
dictExportFile: testFile('dict_export.ts'),
constExportFile: testFile('const_export.ts'),
emptyTsconfig: testFile('tsconfig.json'),
};
describe('exports.ts', () => {
test('empty ts file has empty exports', async () => {
const request = { filenames: [testFiles.emptyFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.emptyFile]: []
});
});
test('add file has just a default export', async () => {
const request = { filenames: [testFiles.addFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.addFile]: [{
type: 'default',
range: {
start: { line: 0, column: 0 },
end: { line: 2, column: 1 }
}
}]
});
});
test('complex file has default and normal export', async () => {
const request = { filenames: [testFiles.complexFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.complexFile]: [
{
type: 'default',
range: {
start: { line: 0, column: 0 },
end: { line: 2, column: 1 }
}
},
{
type: 'named', name: 'isEven',
range: {
start: { line: 4, column: 0 },
end: { line: 8, column: 1 }
}
},
{
type: 'named', name: 'isOdd',
range: {
start: { line: 10, column: 0 },
end: { line: 14, column: 1 }
}
}
]
});
});
test('dict_export file shows names for each export in dict', async () => {
const request = { filenames: [testFiles.dictExportFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.dictExportFile]: [
{ type: 'named', name: 'add' },
{ type: 'named', name: 'sub' },
],
});
});
test('empty ts file works with empty tsconfig', async () => {
const request = { filenames: [testFiles.emptyFile], tsconfig: testFiles.emptyTsconfig };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.emptyFile]: []
});
});
test('`export const` shows up in export list', async () => {
const request = { filenames: [testFiles.constExportFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.constExportFile]: [{
type: 'named', name: 'isEven', range: {
start: { line: 0, column: 13 },
end: { line: 2, column: 1 }
}
}]
});
})
});

View File

@ -0,0 +1,104 @@
{
"include": ["src/**/*"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ESNext", /* Specify what module code is generated. */
"rootDir": "src", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -43,7 +43,7 @@ PRUNE_JUICE_CMD="$(install_dev_tool prune-juice) && $(dev_tool_path prune-juice)
ORMOLU_BASE_CMD="$(install_dev_tool ormolu) && $(dev_tool_path ormolu) --color always --check-idempotence"
ORMOLU_CHECK_CMD="$ORMOLU_BASE_CMD --mode check "'$'"(git ls-files '*.hs' '*.hs-boot')"
ORMOLU_FORMAT_CMD="$ORMOLU_BASE_CMD --mode inplace "'$'"(git ls-files '*.hs' '*.hs-boot')"
WASP_DEPLOY_COMPILE="$SCRIPT_DIR/tools/install_deploy_package_to_data_dir.sh"
WASP_PACKAGES_COMPILE="$SCRIPT_DIR/tools/install_packages_to_data_dir.sh"
echo_and_eval () {
echo -e $"${LIGHT_CYAN}Running:${DEFAULT_COLOR}" $1 "\n"
@ -182,8 +182,8 @@ case $COMMAND in
module-graph)
echo_and_eval "graphmod --quiet --prune-edges $PROJECT_ROOT/src/**/*.hs | dot -Gsize=60,60! -Tpng -o module-graph.png" && echo "Printed module graph to module-graph.png."
;;
wasp-deploy:compile)
echo_and_eval "$WASP_DEPLOY_COMPILE"
wasp-packages:compile)
echo_and_eval "$WASP_PACKAGES_COMPILE"
;;
*)
print_usage

View File

@ -47,6 +47,7 @@ module Wasp.Analyzer.Parser.CST.Traverse
widthAt,
offsetAt,
offsetAfter,
spanAt,
parentKind,
nodeAt,
parentNode,
@ -74,6 +75,7 @@ import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Maybe (isJust)
import Wasp.Analyzer.Parser.CST (SyntaxKind, SyntaxNode (snodeChildren, snodeKind, snodeWidth))
import Wasp.Analyzer.Parser.SourceOffset (SourceOffset)
import Wasp.Analyzer.Parser.SourceSpan (SourceSpan (SourceSpan))
import Wasp.Util.Control.Monad (untilM)
-- | An in-progress traversal through some tree @f@.
@ -265,6 +267,10 @@ offsetAt t = tlCurrentOffset (currentLevel t)
offsetAfter :: Traversal -> SourceOffset
offsetAfter t = offsetAt t + widthAt t
-- | Get the 'SourceSpan' of the current node in the source text.
spanAt :: Traversal -> SourceSpan
spanAt t = SourceSpan (offsetAt t) (offsetAfter t)
-- | Get the "SyntaxKind" of the parent of the current position.
--
-- [Property] @'parentKind' t == 'contentAt' (t & 'up')@

View File

@ -5,12 +5,17 @@ module Wasp.Analyzer.Parser.SourcePosition
)
where
import Data.Aeson (FromJSON (parseJSON), withObject, (.:))
import Wasp.Analyzer.Parser.SourceOffset (SourceOffset)
-- | The first character on the first line is at position @Position 1 1@
-- @SourcePosition <line> <column>@
data SourcePosition = SourcePosition Int Int deriving (Eq)
instance FromJSON SourcePosition where
parseJSON = withObject "SourcePosition" $ \v ->
SourcePosition <$> v .: "line" <*> v .: "column"
instance Show SourcePosition where
show (SourcePosition line column) = show line ++ ":" ++ show column

91
waspc/src/Wasp/Package.hs Normal file
View File

@ -0,0 +1,91 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TypeApplications #-}
module Wasp.Package
( Package (..),
getPackageProc,
)
where
import Control.Monad.Extra (unlessM)
import StrongPath (Abs, Dir, File, Path', Rel, fromAbsDir, fromAbsFile, reldir, relfile, (</>))
import System.Directory (doesDirectoryExist)
import System.Exit (ExitCode (ExitFailure, ExitSuccess), exitFailure)
import System.IO (hPutStrLn, stderr)
import qualified System.Process as P
import Wasp.Data (DataDir)
import qualified Wasp.Data as Data
import Wasp.Node.Version (getAndCheckNodeVersion)
data Package
= DeployPackage
| TsInspectPackage
data PackagesDir
data PackageDir
data PackageScript
packagesDirInDataDir :: Path' (Rel DataDir) (Dir PackagesDir)
packagesDirInDataDir = [reldir|packages|]
packageDirInPackagesDir :: Package -> Path' (Rel PackagesDir) (Dir PackageDir)
packageDirInPackagesDir DeployPackage = [reldir|deploy|]
packageDirInPackagesDir TsInspectPackage = [reldir|ts-inspect|]
scriptInPackageDir :: Path' (Rel PackageDir) (File PackageScript)
scriptInPackageDir = [relfile|dist/index.js|]
-- | Get a 'P.CreateProcess' for a particular package.
--
-- These packages are built during CI/locally via the @tools/install_packages_to_data_dir.sh@
-- script.
--
-- If the package does not have its dependencies installed yet (i.e. after they
-- just installed a Wasp version), we install the dependencies.
getPackageProc :: Package -> [String] -> IO P.CreateProcess
getPackageProc package args = do
getAndCheckNodeVersion >>= \case
Right _ -> pure ()
Left errorMsg -> do
-- Exit if valid node version is not installed
hPutStrLn stderr errorMsg
exitFailure
packageDir <- getPackageDir package
let scriptFile = packageDir </> scriptInPackageDir
ensurePackageDependenciesAreInstalled packageDir
return $ packageProc packageDir "node" (fromAbsFile scriptFile : args)
getPackageDir :: Package -> IO (Path' Abs (Dir PackageDir))
getPackageDir package = do
waspDataDir <- Data.getAbsDataDirPath
let packageDir = waspDataDir </> packagesDirInDataDir </> packageDirInPackagesDir package
return packageDir
-- | Runs @npm install@ if @node_modules@ does not exist in the package directory.
ensurePackageDependenciesAreInstalled :: Path' Abs (Dir PackageDir) -> IO ()
ensurePackageDependenciesAreInstalled packageDir =
unlessM nodeModulesDirExists $ do
let npmInstallCreateProcess = packageProc packageDir "npm" ["install"]
(exitCode, _out, err) <- P.readCreateProcessWithExitCode npmInstallCreateProcess ""
case exitCode of
ExitFailure _ -> do
-- Exit if node_modules fails to install
hPutStrLn stderr $ "Failed to install NPM dependencies for package. Please report this issue: " ++ err
exitFailure
ExitSuccess -> pure ()
where
nodeModulesDirExists = doesDirectoryExist $ fromAbsDir nodeModulesDir
nodeModulesDir = packageDir </> [reldir|node_modules|]
-- | Like 'P.proc', but sets up the cwd to the given package directory.
--
-- NOTE: do not export this function! users of this module should have to go
-- through 'getPackageProc', which makes sure node_modules are present.
packageProc ::
Path' Abs (Dir PackageDir) ->
String ->
[String] ->
P.CreateProcess
packageProc packageDir cmd args = (P.proc cmd args) {P.cwd = Just $ fromAbsDir packageDir}

View File

@ -4,46 +4,44 @@ module Wasp.Project.Deployment
)
where
import Control.Concurrent (newChan)
import Control.Concurrent.Async (concurrently)
import Control.Monad (void)
import Control.Monad.Extra (whenMaybeM)
import Data.Text (Text)
import qualified Data.Text.IO as T.IO
import StrongPath (Abs, Dir, Path', reldir, relfile, toFilePath, (</>))
import System.Directory (doesDirectoryExist, doesFileExist)
import StrongPath (Abs, Dir, Path', relfile, toFilePath, (</>))
import System.Directory (doesFileExist)
import System.Exit (ExitCode (..))
import qualified Wasp.Data as Data
import qualified Wasp.Generator.Job as J
import Wasp.Generator.Job.IO (printJobMsgsUntilExitReceived)
import Wasp.Generator.Job.Process (runNodeCommandAsJob)
import qualified System.Process as P
import Wasp.Package (Package (DeployPackage), getPackageProc)
import Wasp.Project.Common (WaspProjectDir)
import Wasp.Util (unlessM)
loadUserDockerfileContents :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe Text)
loadUserDockerfileContents waspDir = do
let dockerfileAbsPath = toFilePath $ waspDir </> [relfile|Dockerfile|]
whenMaybeM (doesFileExist dockerfileAbsPath) $ T.IO.readFile dockerfileAbsPath
-- | This will run our TS deploy project by passing all args from the Wasp CLI straight through.
-- The TS project is compiled to JS in CI and included in the data dir for the release archive.
-- If the project was not yet built locally (i.e. after they just installed a Wasp version), we do so.
deploy :: FilePath -> Path' Abs (Dir WaspProjectDir) -> [String] -> IO (Either String ())
deploy ::
-- | Path to wasp executable.
FilePath ->
Path' Abs (Dir WaspProjectDir) ->
-- | All arguments from the Wasp CLI.
[String] ->
IO (Either String ())
deploy waspExe waspDir cmdArgs = do
waspDataDir <- Data.getAbsDataDirPath
let deployDir = waspDataDir </> [reldir|packages/deploy|]
let nodeModulesDirExists = doesDirectoryExist . toFilePath $ deployDir </> [reldir|node_modules|]
unlessM nodeModulesDirExists $
void $ runCommandAndPrintOutput $ runNodeCommandAsJob deployDir "npm" ["install"] J.Server
let deployScriptArgs = ["dist/index.js"] ++ cmdArgs ++ ["--wasp-exe", waspExe, "--wasp-project-dir", toFilePath waspDir]
-- NOTE: Here we are lying by saying we are running in the J.Server context.
-- TODO: Consider adding a new context for these types of things, like J.Other or J.External.
runCommandAndPrintOutput $ runNodeCommandAsJob deployDir "node" deployScriptArgs J.Server
where
runCommandAndPrintOutput :: J.Job -> IO (Either String ())
runCommandAndPrintOutput job = do
chan <- newChan
(_, exitCode) <- concurrently (printJobMsgsUntilExitReceived chan) (job chan)
case exitCode of
ExitSuccess -> return $ Right ()
ExitFailure code -> return $ Left $ "Deploy command failed with exit code: " ++ show code
let deployScriptArgs = concat [cmdArgs, ["--wasp-exe", waspExe, "--wasp-project-dir", toFilePath waspDir]]
cp <- getPackageProc DeployPackage deployScriptArgs
-- Set up the process so that it:
-- - Inherits handles from the waspc process (it will print and read from stdin/out/err)
-- - Delegates Ctrl+C: when waspc receives Ctrl+C while this process is running,
-- it will properly shut-down the child process.
-- See https://hackage.haskell.org/package/process-1.6.17.0/docs/System-Process.html#g:4.
let cpInheritHandles =
cp
{ P.std_in = P.Inherit,
P.std_out = P.Inherit,
P.std_err = P.Inherit,
P.delegate_ctlc = True
}
exitCode <- P.withCreateProcess cpInheritHandles $ \_ _ _ ph -> P.waitForProcess ph
case exitCode of
ExitSuccess -> return $ Right ()
ExitFailure code -> return $ Left $ "Deploy command failed with exit code: " ++ show code

View File

@ -0,0 +1,114 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Wasp.TypeScript
( -- * Getting Information About TypeScript Files
-- Internally, this module calls out to @packages/ts-inspect@, which uses
-- the TypeScript compiler API.
--
-- Despite all of the names and descriptions referring to just TypeScript,
-- this module also supports JavaScript files.
-- * Export lists
getExportsOfTsFiles,
TsExportRequest (..),
TsExportResponse (..),
TsExport (..),
tsExportSourceRegion,
)
where
import Data.Aeson (FromJSON (parseJSON), ToJSON (toEncoding), Value, decode, defaultOptions, encode, genericToEncoding, withObject, (.:), (.:!))
import qualified Data.ByteString.Lazy.UTF8 as BS
import Data.Conduit.Process.Typed (ExitCode (ExitSuccess))
import qualified Data.HashMap.Strict as M
import GHC.Generics (Generic)
import qualified System.Process as P
import Wasp.Analyzer (SourcePosition)
import Wasp.Analyzer.Parser.SourcePosition (SourcePosition (SourcePosition))
import Wasp.Analyzer.Parser.SourceRegion (SourceRegion (SourceRegion))
import Wasp.Package (Package (TsInspectPackage), getPackageProc)
-- | Attempt to get list of exported names from TypeScript files.
--
-- The 'FilePath's in the response are guaranteed to exactly match the
-- corresponding 'FilePath' in the request.
getExportsOfTsFiles :: [TsExportRequest] -> IO (Either String TsExportResponse)
getExportsOfTsFiles requests = do
let requestJSON = BS.toString $ encode $ groupExportRequests requests
cp <- getPackageProc TsInspectPackage []
(exitCode, response, err) <- P.readCreateProcessWithExitCode cp requestJSON
case exitCode of
ExitSuccess -> case decode $ BS.fromString response of
Nothing -> return $ Left $ "invalid response JSON from ts-inspect: " ++ response
Just exports -> return $ Right exports
_ -> return $ Left err
-- | Join export requests that have the same tsconfig. The @ts-inspect@ package
-- runs an instance of the TypeScript compiler per request group, so grouping
-- them this way improves performance.
groupExportRequests :: [TsExportRequest] -> [TsExportRequest]
groupExportRequests requests =
map (uncurry $ flip TsExportRequest) $
M.toList $ foldr insertRequest M.empty requests
where
insertRequest (TsExportRequest names maybeTsconfig) grouped =
M.insertWith (++) maybeTsconfig names grouped
-- | A symbol exported from a TypeScript file.
data TsExport
= -- | @export default ...@
DefaultExport !(Maybe SourceRegion)
| -- | @export const name ...@
NamedExport !String !(Maybe SourceRegion)
deriving (Show, Eq)
-- | Get the position of an export in the TypeScript file, if that information
-- is available.
tsExportSourceRegion :: TsExport -> Maybe SourceRegion
tsExportSourceRegion (DefaultExport sourceRegion) = sourceRegion
tsExportSourceRegion (NamedExport _ sourceRegion) = sourceRegion
instance FromJSON TsExport where
-- The JSON response gives zero-based source positions. This parser takes care
-- of converting to the expected one-based positions for 'SourcePosition'.
parseJSON = withObject "TsExport" $ \v ->
(v .: "type") >>= \case
"default" -> DefaultExport . fmap toSourceRegion <$> v .:! "range"
"named" -> NamedExport <$> v .: "name" <*> (fmap toSourceRegion <$> v .:! "range")
(_ :: Value) -> fail "invalid type for TsExport"
-- | Map from TypeScript files to the list of exports found in that file.
newtype TsExportResponse = TsExportResponse (M.HashMap FilePath [TsExport])
deriving (Eq, Show, FromJSON)
-- | A list of files associated with an optional tsconfig file that is run
-- through the TypeScript compiler as a group.
data TsExportRequest = TsExportRequest {filenames :: ![FilePath], tsconfig :: !(Maybe FilePath)}
deriving (Eq, Show, Generic)
instance ToJSON TsExportRequest where
toEncoding = genericToEncoding defaultOptions
-- Wrapper types for parsing SourceRegions from data with 0-based offsets.
newtype ZeroBasedSourceRegion = ZeroBasedSourceRegion {toSourceRegion :: SourceRegion}
instance FromJSON ZeroBasedSourceRegion where
parseJSON = withObject "range" $ \v ->
ZeroBasedSourceRegion
<$> ( SourceRegion
<$> (toSourcePos <$> v .: "start")
<*> (toSourcePos <$> v .: "end")
)
newtype ZeroBasedSourcePosition = ZeroBasedSourcePosition {toSourcePos :: SourcePosition}
instance FromJSON ZeroBasedSourcePosition where
parseJSON = withObject "location" $ \v ->
ZeroBasedSourcePosition
<$> ( SourcePosition
<$> ((+ 1) <$> v .: "line")
<*> ((+ 1) <$> v .: "column")
)

View File

@ -1,16 +0,0 @@
#!/bin/sh -e
# Helper to compile the waspc/packages/deploy package locally and in CI.
# It will then move it into the Cabal data dir (and thus, the installer archive in CI releases).
# Gets the directory of where this script lives.
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$dir/../packages/deploy"
npm install
npm run build
rm -rf ./node_modules
cd "$dir/.."
rm -rf ./data/packages
cp -R ./packages ./data

View File

@ -0,0 +1,22 @@
#!/bin/bash -e
# Helper to compile the waspc/packages/* packages locally and in CI.
# It will then move it into the Cabal data dir (and thus, the installer archive in CI releases).
# Gets the directory of where this script lives.
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
for package in $(ls "$dir/../packages"); do
package_dir="$dir/../packages/$package"
if [[ -d "$package_dir" ]]; then
echo "Installing $package ($package_dir)"
cd "$package_dir"
npm install
npm run build
rm -rf ./node_modules
fi
done
cd "$dir/.."
rm -rf ./data/packages
cp -R ./packages ./data

View File

@ -53,6 +53,12 @@ data-files:
Cli/templates/basic/.wasproot
Cli/templates/basic/src/.waspignore
Cli/templates/basic/main.wasp
packages/deploy/dist/**/*.js
packages/deploy/package.json
packages/deploy/package-lock.json
packages/ts-inspect/dist/**/*.js
packages/ts-inspect/package.json
packages/ts-inspect/package-lock.json
data-dir: data/
source-repository head
@ -300,6 +306,7 @@ library
Wasp.Generator.WebAppGenerator.CrudG
Wasp.Generator.WriteFileDrafts
Wasp.Node.Version
Wasp.Package
Wasp.Project
Wasp.Project.Analyze
Wasp.Project.Common
@ -316,6 +323,7 @@ library
Wasp.Psl.Parser.Model
Wasp.Psl.Util
Wasp.SemanticVersion
Wasp.TypeScript
Wasp.Util
Wasp.Util.Network.Socket
Wasp.Util.Control.Monad
@ -335,13 +343,17 @@ library waspls
exposed-modules:
Control.Monad.Log
Control.Monad.Log.Class
Wasp.LSP.Debouncer
Wasp.LSP.Server
Wasp.LSP.ServerState
Wasp.LSP.ServerConfig
Wasp.LSP.ServerM
Wasp.LSP.ExtImport
Wasp.LSP.Handlers
Wasp.LSP.Diagnostic
Wasp.LSP.Completion
Wasp.LSP.GotoDefinition
Wasp.LSP.Reactor
Wasp.LSP.Completions.Common
Wasp.LSP.Completions.DictKeyCompletion
Wasp.LSP.Completions.ExprCompletion
@ -359,6 +371,14 @@ library waspls
, lens ^>=5.1
, lsp ^>=1.4.0.0
, lsp-types ^>=1.4.0.1
, stm ^>=2.5.1.0
, stm-containers ^>=1.2
, hashable ^>=1.3.5.0
, unordered-containers
, strong-path
, path
, async ^>=2.2.4
, unliftio-core
, mtl
, text
, transformers ^>=0.5.6.2
@ -551,6 +571,7 @@ test-suite waspls-test
, filepath
other-modules:
Wasp.LSP.CompletionTest
Wasp.LSP.DebouncerTest
test-suite cli-test
import: common-all, common-exe

45
waspc/waspls/README.md Normal file
View File

@ -0,0 +1,45 @@
# waspls Architecture
waspls uses the [lsp](https://hackage.haskell.org/package/lsp) library for
interacting with the Language Server Protocol.
The main entry point is `serve` in `Wasp/LSP/Server.hs`. This function sets up
the LSP server, the reactor thread, and starts everything.
The handlers to LSP notifications and requests are defined in `Wasp/LSP/Handlers.hs`
and imported by `Wasp/LSP/Server.hs` so that the `lsp` package can be told about
them. Mostly, these handlers are small functions to call out to the actual
implementation in another source file.
There are two types of handlers in waspls:
1. "Analysis handlers" that extract some syntactic or semantic information from
source code and store it in the server state.
2. "Request handlers" that use the information stored in the server state to
provide LSP features, such as autocompletion and goto definition.
Request handlers have read-only access to the server state, while analysis
handlers can write to the state.
## Multithreading
By default, the `lsp` package is single-threaded. Because waspls sometimes
needs to do time-intensive work, such as getting the list of exported symbols
from TypeScript files, there is a mechanism for running code on a separate
thread. The purpose of this is to avoid blocking the main thread for long periods
of time, which would make the language server feel unresponsive in the editor.
This mechanism is the "reactor thread," which continuously looks for new actions
to run on a separate thread. For details, see the documentation in `Wasp/LSP/Reactor.hs`.
# Testing Development Versions of waspls
Set the wasp executable path in the Wasp VSCode extension to `wasp-cli`. To
build the LSP, run `cabal install`. Then, restart the language server in VSCode
by running the `Wasp: Restart Wasp LSP Server` command. By default, you can
press <kbd>Ctrl+Shift+P</kbd> to open the command palette to search for the
command.
Note that, after changing the executable path in the extension settings, you
have to reload the VSCode window. You can do this either by closing and reopening
the window or by running the command `Developer: Reload Window`.

View File

@ -5,7 +5,7 @@ where
import Control.Lens ((^.))
import Control.Monad.Log.Class (MonadLog (logM))
import Control.Monad.State.Class (MonadState, gets)
import Control.Monad.Reader.Class (MonadReader, asks)
import Data.List (sortOn)
import qualified Language.LSP.Types as LSP
import qualified Language.LSP.Types.Lens as LSP
@ -18,12 +18,12 @@ import Wasp.LSP.Syntax (locationAtOffset, lspPositionToOffset, showNeighborhood)
-- | Get the list of completions at a (line, column) position in the source.
getCompletionsAtPosition ::
(MonadState ServerState m, MonadLog m) =>
(MonadReader ServerState m, MonadLog m) =>
LSP.Position ->
m [LSP.CompletionItem]
getCompletionsAtPosition position = do
src <- gets (^. currentWaspSource)
maybeSyntax <- gets (^. cst)
src <- asks (^. currentWaspSource)
maybeSyntax <- asks (^. cst)
case maybeSyntax of
-- If there is no syntax tree, make no completions
Nothing -> return []

View File

@ -0,0 +1,44 @@
module Wasp.LSP.Debouncer
( Debouncer,
newDebouncerIO,
debounce,
)
where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (Async, async, cancel)
import Control.Concurrent.STM (atomically)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.IO.Unlift (MonadUnliftIO, toIO)
import Data.Foldable (traverse_)
import Data.Hashable (Hashable)
import qualified StmContainers.Map as STM
-- | Debounce events named with type @k@. Each unique @k@ (by its 'Eq' instance)
-- has its own debounce timer. Construct a debouncer with 'newDebouncerIO'.
--
-- See 'debounce' for how to use it.
newtype Debouncer k = Debouncer (STM.Map k (Async ()))
newDebouncerIO :: IO (Debouncer k)
newDebouncerIO = Debouncer <$> STM.newIO
-- | @debounce debouncer waitMicros event action@ waits @waitMicros@ microseconds
-- and then runs @action@.
--
-- If 'debounce' is called again with the same @event@, only the newer call
-- fires.
debounce :: (MonadUnliftIO m, MonadIO m, Eq k, Hashable k) => Debouncer k -> Int -> k -> m () -> m ()
debounce (Debouncer running) waitMicros event fire = do
fireIO <- toIO fire
a <- liftIO $
async $ do
threadDelay waitMicros
fireIO
atomically $ STM.delete event running
prev <- liftIO $
atomically $ do
prev <- STM.lookup event running
STM.insert a event running
return prev
liftIO $ traverse_ cancel prev

View File

@ -1,38 +1,76 @@
module Wasp.LSP.Diagnostic
( waspErrorToDiagnostic,
concreteParseErrorToDiagnostic,
( WaspDiagnostic (..),
MissingImportReason (..),
waspDiagnosticToLspDiagnostic,
clearMissingImportDiagnostics,
)
where
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Language.LSP.Types as LSP
import qualified StrongPath as SP
import qualified Wasp.Analyzer.AnalyzeError as W
import qualified Wasp.Analyzer.Parser as W
import qualified Wasp.Analyzer.Parser.ConcreteParser.ParseError as CPE
import Wasp.Analyzer.Parser.Ctx (getCtxRgn)
import Wasp.Analyzer.Parser.SourcePosition (SourcePosition (..), sourceOffsetToPosition)
import Wasp.Analyzer.Parser.SourceRegion (sourceSpanToRegion)
import Wasp.Analyzer.Parser.SourceSpan (SourceSpan (..))
import Wasp.LSP.ServerM (ServerM, logM)
import Wasp.LSP.Util (waspSourceRegionToLspRange)
concreteParseErrorToDiagnostic :: String -> CPE.ParseError -> ServerM LSP.Diagnostic
data WaspDiagnostic
= ParseDiagnostic !CPE.ParseError
| AnalyzerDiagonstic !W.AnalyzeError
| MissingImportDiagnostic !SourceSpan !MissingImportReason !(SP.Path' SP.Abs SP.File')
deriving (Eq, Show)
data MissingImportReason = NoDefaultExport | NoNamedExport !String | NoFile
deriving (Eq, Show)
showMissingImportReason :: MissingImportReason -> SP.Path' SP.Abs SP.File' -> Text
showMissingImportReason NoDefaultExport tsFile =
"No default export in " <> Text.pack (SP.fromAbsFile tsFile)
showMissingImportReason (NoNamedExport name) tsFile =
"`" <> Text.pack name <> "` is not exported from " <> Text.pack (SP.fromAbsFile tsFile)
showMissingImportReason NoFile tsFile =
Text.pack (SP.fromAbsFile tsFile) <> " does not exist"
missingImportSeverity :: MissingImportReason -> LSP.DiagnosticSeverity
missingImportSeverity _ = LSP.DsError
waspDiagnosticToLspDiagnostic :: String -> WaspDiagnostic -> LSP.Diagnostic
waspDiagnosticToLspDiagnostic src (ParseDiagnostic err) = concreteParseErrorToDiagnostic src err
waspDiagnosticToLspDiagnostic _ (AnalyzerDiagonstic analyzeError) = waspErrorToDiagnostic analyzeError
waspDiagnosticToLspDiagnostic src (MissingImportDiagnostic sourceSpan reason tsFile) =
let message = showMissingImportReason reason tsFile
severity = missingImportSeverity reason
region = sourceSpanToRegion src sourceSpan
range = waspSourceRegionToLspRange region
in LSP.Diagnostic
{ _range = range,
_severity = Just severity,
_code = Nothing,
_source = Just "ts",
_message = message,
_tags = Nothing,
_relatedInformation = Nothing
}
concreteParseErrorToDiagnostic :: String -> CPE.ParseError -> LSP.Diagnostic
concreteParseErrorToDiagnostic src err =
let message = Text.pack $ showConcreteParseError src err
source = "parse"
range = concreteErrorRange err
in logM ("[concreteParseErroToDiagnostic] _range=" ++ show range)
>> return
( LSP.Diagnostic
{ _range = range,
_severity = Nothing,
_code = Nothing,
_source = Just source,
_message = message,
_tags = Nothing,
_relatedInformation = Nothing
}
)
in LSP.Diagnostic
{ _range = range,
_severity = Just LSP.DsError,
_code = Nothing,
_source = Just source,
_message = message,
_tags = Nothing,
_relatedInformation = Nothing
}
where
concreteErrorRange e = case CPE.errorSpan e of
SourceSpan startOffset endOffset ->
@ -53,7 +91,7 @@ waspErrorToDiagnostic err =
range = waspErrorRange err
in LSP.Diagnostic
{ _range = range,
_severity = Nothing,
_severity = Just LSP.DsError,
_code = Nothing,
_source = Just source,
_message = message,
@ -77,3 +115,9 @@ waspErrorRange :: W.AnalyzeError -> LSP.Range
waspErrorRange err =
let (_, W.Ctx rgn) = W.getErrorMessageAndCtx err
in waspSourceRegionToLspRange rgn
clearMissingImportDiagnostics :: [WaspDiagnostic] -> [WaspDiagnostic]
clearMissingImportDiagnostics = filter (not . isMissingImportDiagnostic)
where
isMissingImportDiagnostic (MissingImportDiagnostic _ _ _) = True
isMissingImportDiagnostic _ = False

View File

@ -0,0 +1,301 @@
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Redundant <$>" #-}
module Wasp.LSP.ExtImport
( -- * TS Export lists
refreshAllExports,
refreshExportsForFiles,
-- * Diagnostics and Syntax
ExtImportNode (..),
findExtImportAroundLocation,
ExtImportLookupResult (..),
lookupExtImport,
updateMissingImportDiagnostics,
getMissingImportDiagnostics,
)
where
import Control.Applicative ((<|>))
import Control.Arrow (Arrow (first), (&&&))
import Control.Lens ((%~), (^.))
import Control.Monad (unless, void)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Log.Class (logM)
import Control.Monad.Reader.Class (asks)
import Control.Monad.Trans.Maybe (MaybeT (runMaybeT))
import qualified Data.HashMap.Strict as M
import Data.List (find, stripPrefix)
import Data.Maybe (catMaybes, fromJust, isNothing, mapMaybe)
import qualified Language.LSP.Server as LSP
import qualified Path as P
import qualified StrongPath as SP
import qualified StrongPath.Path as SP
import Text.Read (readMaybe)
import Wasp.Analyzer.Parser (ExtImportName (ExtImportField, ExtImportModule))
import qualified Wasp.Analyzer.Parser.CST as S
import Wasp.Analyzer.Parser.CST.Traverse (Traversal)
import qualified Wasp.Analyzer.Parser.CST.Traverse as T
import Wasp.LSP.Diagnostic (MissingImportReason (NoDefaultExport, NoFile, NoNamedExport), WaspDiagnostic (MissingImportDiagnostic), clearMissingImportDiagnostics)
import Wasp.LSP.ServerM (HandlerM, ServerM, handler, modify)
import qualified Wasp.LSP.ServerState as State
import Wasp.LSP.Syntax (findChild, lexemeAt)
import Wasp.LSP.Util (hoistMaybe)
import Wasp.Project (WaspProjectDir)
import qualified Wasp.TypeScript as TS
import Wasp.Util.IO (doesFileExist)
-- | Finds all external imports and refreshes the export cache for the relevant
-- files.
refreshAllExports :: ServerM ()
refreshAllExports = do
(src, maybeCst) <- handler $ asks ((^. State.currentWaspSource) &&& (^. State.cst))
maybeWaspRoot <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
case (,) <$> maybeCst <*> maybeWaspRoot of
Nothing -> pure ()
Just (syntax, waspRoot) -> do
let allExtImports = findAllExtImports src syntax
allTsFiles <- catMaybes <$> mapM (absPathForExtImport waspRoot) allExtImports
refreshExportsForFiles allTsFiles
-- | Refresh the export cache for the given JS/TS files. This can take a while:
-- generally half a second to a second. It is recommended that this is run in
-- the reactor thread so it does not block other LSP requests from being
-- responded to.
refreshExportsForFiles :: [SP.Path' SP.Abs SP.File'] -> ServerM ()
refreshExportsForFiles files = do
logM $ "[refreshExportsForFile] refreshing export lists for " ++ show files
-- First, remove any deleted files from the cache
mapM_ clearCacheForFileIfMissing files
LSP.getRootPath >>= \case
Nothing -> pure ()
Just projectDirFilepath -> do
-- NOTE: getRootPath always returns a valid absolute path or 'Nothing'.
let projectDir = fromJust $ SP.parseAbsDir projectDirFilepath
let exportRequests = mapMaybe (getExportRequestForFile projectDir) files
liftIO (TS.getExportsOfTsFiles exportRequests) >>= \case
Left err -> do
logM $ "[refreshExportsForFile] ERROR getting exports: " ++ show err
Right res -> updateExportsCache res
where
getExportRequestForFile projectDir file =
([SP.fromAbsFile file] `TS.TsExportRequest`) . Just . SP.fromAbsFile <$> tryGetTsconfigForFile projectDir file
-- Removes deleted files from cache
clearCacheForFileIfMissing file = do
fileExists <- liftIO $ doesFileExist file
unless fileExists $ modify (State.tsExports %~ M.insert file [])
-- | Look for the tsconfig file for the specified JS/TS file.
--
-- To do this, it checks if the file is inside src/client or src/server and
-- returns the respective tsconfig path if so (src/client/tsconfig.json or
-- src/server/tsconfig.json).
tryGetTsconfigForFile :: SP.Path' SP.Abs (SP.Dir WaspProjectDir) -> SP.Path' SP.Abs SP.File' -> Maybe (SP.Path' SP.Abs SP.File')
tryGetTsconfigForFile waspRoot file = tsconfigPath [SP.reldir|src/client|] <|> tsconfigPath [SP.reldir|src/server|]
where
tsconfigPath :: SP.Path' (SP.Rel WaspProjectDir) SP.Dir' -> Maybe (SP.Path' SP.Abs SP.File')
tsconfigPath folder =
let absFolder = waspRoot SP.</> folder
in if SP.toPathAbsDir absFolder `P.isProperPrefixOf` SP.toPathAbsFile file
then Just $ absFolder SP.</> [SP.relfile|tsconfig.json|]
else Nothing
updateExportsCache :: TS.TsExportResponse -> ServerM ()
updateExportsCache (TS.TsExportResponse res) = do
let newExports = M.fromList $ map (first exportResKeyToPath) $ M.toList res
void $ modify $ State.tsExports %~ (newExports `M.union`)
where
-- 'TS.getExportsOfTsFiles' should only ever put valid paths in the keys of
-- its response, so we enforce that here.
exportResKeyToPath key = case SP.parseAbsFile key of
Just path -> path
Nothing -> error "updateExportsCache: expected valid path from TS.getExportsOfTsFiles."
-- ------------------------- Diagnostics & Syntax ------------------------------
data ExtImportNode = ExtImportNode
{ -- | Location of the 'S.ExtImport' node.
einLocation :: !Traversal,
einName :: !(Maybe ExtImportName),
-- | Imported filepath, verbatim from the wasp source file.
einFile :: !(Maybe FilePath)
}
-- | Create a 'ExtImportNode' at a location, assuming that the given node is
-- a 'S.ExtImport'.
extImportAtLocation :: String -> Traversal -> ExtImportNode
extImportAtLocation src location =
let maybeName =
(ExtImportModule . lexemeAt src <$> findChild S.ExtImportModule location)
<|> (ExtImportField . lexemeAt src <$> findChild S.ExtImportField location)
maybeFile = lexemeAt src <$> findChild S.ExtImportPath location
in ExtImportNode location maybeName maybeFile
-- | Search for an 'S.ExtImport' node at the current node or as one of its
-- ancestors.
findExtImportAroundLocation ::
-- | Wasp source code.
String ->
-- | Location to look for external import at.
Traversal ->
Maybe ExtImportNode
findExtImportAroundLocation src location = do
extImport <- findExtImportParent location
return $ extImportAtLocation src extImport
where
findExtImportParent t
| T.kindAt t == S.ExtImport = Just t
| otherwise = T.up t >>= findExtImportParent
-- | Gets diagnostics for external imports and appends them to the current
-- list of diagnostics.
updateMissingImportDiagnostics :: ServerM ()
updateMissingImportDiagnostics = do
newDiagnostics <- handler getMissingImportDiagnostics
modify (State.latestDiagnostics %~ ((++ newDiagnostics) . clearMissingImportDiagnostics))
-- | Get diagnostics for external imports with missing definitions. Uses the
-- cached export lists.
getMissingImportDiagnostics :: HandlerM [WaspDiagnostic]
getMissingImportDiagnostics =
asks (^. State.cst) >>= \case
Nothing -> return []
Just syntax -> do
src <- asks (^. State.currentWaspSource)
let allExtImports = findAllExtImports src syntax
catMaybes <$> mapM findDiagnosticForExtImport allExtImports
-- Finds all external imports in a concrete syntax tree.
findAllExtImports :: String -> [S.SyntaxNode] -> [ExtImportNode]
findAllExtImports src syntax = go $ T.fromSyntaxForest syntax
where
-- Recurse through syntax tree and find all 'S.ExtImport' nodes.
go :: Traversal -> [ExtImportNode]
go t = case T.kindAt t of
S.ExtImport -> [extImportAtLocation src t]
_ -> concatMap go $ T.children t
-- | The result of 'lookupExtImport'.
data ExtImportLookupResult
= -- | There is a syntax error in the ExtImport.
ImportSyntaxError
| -- | The imported file exists but is not in cached export list.
ImportCacheMiss
| -- | The imported file does not exist.
ImportedFileDoesNotExist (SP.Path' SP.Abs SP.File')
| -- | Imports a symbol that is not exported from the file it imports.
ImportedSymbolDoesNotExist (SP.Path' SP.Abs SP.File')
| -- | Sucessful lookup: includes the file and exported symbol.
ImportsSymbol (SP.Path' SP.Abs SP.File') TS.TsExport
deriving (Eq, Show)
-- | Search the cached export list for the export that the 'ExtImportNode'
-- imports, if any exists.
lookupExtImport :: ExtImportNode -> HandlerM ExtImportLookupResult
lookupExtImport extImport = do
maybeWaspRoot <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
case maybeWaspRoot of
Nothing -> return ImportSyntaxError
Just waspRoot -> do
absPathForExtImport waspRoot extImport >>= \case
Nothing -> do
return ImportSyntaxError
Just tsFile ->
asks ((M.!? tsFile) . (^. State.tsExports)) >>= \case
Nothing -> lookupCacheMiss tsFile
Just exports -> lookupCacheHit tsFile exports
where
lookupCacheMiss tsFile = do
tsFileExists <- liftIO $ doesFileExist tsFile
if tsFileExists
then return ImportCacheMiss
else return $ ImportedFileDoesNotExist tsFile
lookupCacheHit tsFile exports = case maybeIsImportedExport of
Nothing -> return ImportSyntaxError
Just isImportedExport -> do
case find isImportedExport exports of
Just export -> return $ ImportsSymbol tsFile export
Nothing -> return $ ImportedSymbolDoesNotExist tsFile
-- A predicate to check if a TsExport matches the ExtImport, assuming the
-- export is from the correct file.
maybeIsImportedExport = case einName extImport of
Nothing -> Nothing
Just (ExtImportModule _) -> Just $ \case
TS.DefaultExport _ -> True
_ -> False
Just (ExtImportField name) -> Just $ \case
TS.NamedExport n _ | n == name -> True
_ -> False
-- | Check a single external import and see if it points to a real exported
-- function in a source file.
--
-- If the file is not in the cache, no diagnostic is reported because that would
-- risk showing incorrect diagnostics.
findDiagnosticForExtImport :: ExtImportNode -> HandlerM (Maybe WaspDiagnostic)
findDiagnosticForExtImport extImport =
lookupExtImport extImport >>= \case
ImportSyntaxError -> do
logM $ "[getMissingImportDiagnostics] ignoring extimport with a syntax error " ++ show extImportSpan
return Nothing
ImportCacheMiss -> return Nothing
ImportedFileDoesNotExist tsFile -> return $ Just $ MissingImportDiagnostic extImportSpan NoFile tsFile
ImportedSymbolDoesNotExist tsFile -> return $ Just $ diagnosticForExtImport tsFile
ImportsSymbol _ _ -> return Nothing -- Valid extimport, no diagnostic to report.
where
diagnosticForExtImport tsFile = case einName extImport of
Nothing -> error "diagnosticForExtImport called for nameless ext import. This should never happen."
Just (ExtImportModule _) -> MissingImportDiagnostic extImportSpan NoDefaultExport tsFile
Just (ExtImportField name) -> MissingImportDiagnostic extImportSpan (NoNamedExport name) tsFile
extImportSpan = T.spanAt $ einLocation extImport
-- | Convert the path inside an external import in a .wasp file to an absolute
-- path.
--
-- To support module resolution, this first tries to find the file with the
-- exact extension, otherwise it tries to replace @.js@ with @.ts@ or it tries
-- to append @.js@, @.jsx@, @.ts@, @.tsx@ if the file has no extension.
absPathForExtImport ::
(MonadIO m) =>
SP.Path' SP.Abs SP.Dir' ->
ExtImportNode ->
m (Maybe (SP.Path' SP.Abs SP.File'))
absPathForExtImport waspRoot extImport = runMaybeT $ do
-- Read the string from the syntax tree
extImportPath :: FilePath <- hoistMaybe $ einFile extImport >>= readMaybe
-- Drop the @ and try to parse to a relative path
relPath <- hoistMaybe $ SP.parseRelFile =<< stripPrefix "@" extImportPath
-- Prepend the src directory in the project to the relative path
let absPath = waspRoot SP.</> [SP.reldir|src|] SP.</> relPath
-- Fix the extension, if needed
SP.fromPathAbsFile <$> fixExtension (SP.toPathAbsFile absPath)
where
fixExtension file
| isNothing ext = useExtensionsIfExists [".jsx", ".tsx", ".js", ".ts"] file
| ext == Just ".js" = useExtensionsIfExists [".ts"] file
| otherwise = return file
where
ext = P.fileExtension file
-- Returns @Nothing@ if @file@ does not exist, otherwise returns @Just file@.
ifExists file = do
exists <- liftIO $ doesFileExist $ SP.fromPathAbsFile file
if exists
then return $ Just file
else return Nothing
-- Replaces the extension of @file@ with the left-most extension such that
-- the new file path exists. If no such extension is given, returns the
-- original file path.
useExtensionsIfExists [] file = return file
useExtensionsIfExists (ext : exts) file =
ifExists (fromJust $ P.replaceExtension ext file) >>= \case
Nothing -> useExtensionsIfExists exts file
Just file' -> return file'

View File

@ -0,0 +1,79 @@
module Wasp.LSP.GotoDefinition
( gotoDefinitionOfSymbolAtPosition,
)
where
import Control.Lens ((^.))
import Control.Monad.Log.Class (logM)
import Control.Monad.Reader.Class (asks)
import qualified Language.LSP.Types as LSP
import qualified Language.LSP.Types.Lens as LSP
import qualified StrongPath as SP
import Wasp.Analyzer.Parser.CST.Traverse (Traversal, fromSyntaxForest)
import qualified Wasp.Analyzer.Parser.CST.Traverse as T
import Wasp.Analyzer.Parser.SourceRegion (sourceSpanToRegion)
import qualified Wasp.LSP.ExtImport as ExtImport
import Wasp.LSP.ServerM (HandlerM)
import qualified Wasp.LSP.ServerState as State
import Wasp.LSP.Syntax (locationAtOffset, lspPositionToOffset)
import Wasp.LSP.Util (waspSourceRegionToLspRange)
import qualified Wasp.TypeScript as TS
definitionProviders :: [String -> Traversal -> HandlerM [LSP.LocationLink]]
definitionProviders = [extImportDefinitionProvider]
gotoDefinitionOfSymbolAtPosition :: LSP.Position -> HandlerM (LSP.List LSP.LocationLink)
gotoDefinitionOfSymbolAtPosition position = do
src <- asks (^. State.currentWaspSource)
maybeSyntax <- asks (^. State.cst)
case maybeSyntax of
Nothing -> return $ LSP.List [] -- No syntax tree, can't provide definitions.
Just syntax -> do
-- Run each definition provider and concatenate results.
let offset = lspPositionToOffset src position
let location = locationAtOffset offset (fromSyntaxForest syntax)
definitionLocations <- concat <$> mapM (\f -> f src location) definitionProviders
logM $ "Got definitions at " ++ show position ++ ": " ++ show definitionLocations
return $ LSP.List definitionLocations
-- | If the provided location is within an ExtImport syntax node, returns the
-- location in the JS/TS file of the symbol that the ExtImport points to, if
-- that symbol is defined and the JS/TS file is in the cached export lists.
extImportDefinitionProvider :: String -> Traversal -> HandlerM [LSP.LocationLink]
extImportDefinitionProvider src location =
case ExtImport.findExtImportAroundLocation src location of
Nothing -> return [] -- Not at an external import.
Just extImport -> do
let extImportSpan = T.spanAt $ ExtImport.einLocation extImport
let extImportRange = waspSourceRegionToLspRange $ sourceSpanToRegion src extImportSpan
ExtImport.lookupExtImport extImport >>= \case
ExtImport.ImportsSymbol tsFile tsExport -> do
case TS.tsExportSourceRegion tsExport of
Nothing -> return [link extImportRange $ gotoFile tsFile]
Just sourceRegion -> return [link extImportRange $ gotoRangeInFile tsFile $ waspSourceRegionToLspRange sourceRegion]
_ -> return [] -- Location does not point to a valid exported symbol.
-- | @link linkRange location@ creates a @LSP.LocationLink@ to the same place as
-- @location@ and sets the origin selection range (the range that is highlighted
-- in the editor in the original file) to @linkRange@.
link :: LSP.Range -> LSP.Location -> LSP.LocationLink
link linkRange gotoLocation =
LSP.LocationLink
{ _originSelectionRange = Just linkRange,
_targetUri = gotoLocation ^. LSP.uri,
_targetRange = gotoLocation ^. LSP.range,
_targetSelectionRange = gotoLocation ^. LSP.range
}
-- | Create a 'LSP.Location' pointing to the start of a file.
--
-- This creates a location which points to an absurdly large range of text (1
-- million lines) so that the entire file is selected.
gotoFile :: SP.Path' SP.Abs (SP.File any) -> LSP.Location
gotoFile file = gotoRangeInFile file (LSP.Range (LSP.Position 0 0) (LSP.Position 1000000 0))
-- | Create a 'LSP.Location' pointing to a specific place in a file.
gotoRangeInFile :: SP.Path' SP.Abs (SP.File any) -> LSP.Range -> LSP.Location
gotoRangeInFile file range =
let uri = LSP.filePathToUri $ SP.fromAbsFile file
in LSP.Location {_uri = uri, _range = range}

View File

@ -1,28 +1,44 @@
{-# LANGUAGE DataKinds #-}
module Wasp.LSP.Handlers
( initializedHandler,
shutdownHandler,
didOpenHandler,
didChangeHandler,
didSaveHandler,
completionHandler,
signatureHelpHandler,
gotoDefinitionHandler,
)
where
import Control.Lens ((.~), (?~), (^.))
import Control.Monad (forM_, when, (<=<))
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Log.Class (logM)
import Control.Monad.Reader (asks)
import qualified Data.HashMap.Strict as M
import Data.List (stripPrefix)
import Data.Maybe (isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Language.LSP.Server (Handlers)
import qualified Language.LSP.Server as LSP
import qualified Language.LSP.Types as LSP
import qualified Language.LSP.Types.Lens as LSP
import Language.LSP.VFS (virtualFileText)
import qualified Language.LSP.VFS as LSP
import qualified StrongPath as SP
import Wasp.Analyzer (analyze)
import Wasp.Analyzer.Parser.ConcreteParser (parseCST)
import qualified Wasp.Analyzer.Parser.Lexer as L
import Wasp.LSP.Completion (getCompletionsAtPosition)
import Wasp.LSP.Diagnostic (concreteParseErrorToDiagnostic, waspErrorToDiagnostic)
import Wasp.LSP.ServerM (ServerError (..), ServerM, Severity (..), gets, liftLSP, modify, throwError)
import Wasp.LSP.Debouncer (debounce)
import Wasp.LSP.Diagnostic (WaspDiagnostic (AnalyzerDiagonstic, ParseDiagnostic), waspDiagnosticToLspDiagnostic)
import Wasp.LSP.ExtImport (refreshAllExports, refreshExportsForFiles, updateMissingImportDiagnostics)
import Wasp.LSP.GotoDefinition (gotoDefinitionOfSymbolAtPosition)
import Wasp.LSP.ServerM (HandlerM, ServerM, handler, modify, sendToReactor)
import Wasp.LSP.ServerState (cst, currentWaspSource, latestDiagnostics)
import qualified Wasp.LSP.ServerState as State
import Wasp.LSP.SignatureHelp (getSignatureHelpAtPosition)
-- LSP notification and request handlers
@ -34,13 +50,63 @@ import Wasp.LSP.SignatureHelp (getSignatureHelpAtPosition)
-- The client starts the LSP at its own discretion, but commonly this is done
-- either when:
--
-- - A file of the associated language is opened (in this case `.wasp`)
-- - A file of the associated language is opened (in this case `.wasp`).
-- - A workspace is opened that has a project structure associated with the
-- language (in this case, a `main.wasp` file in the root folder of the
-- workspace)
-- workspace).
initializedHandler :: Handlers ServerM
initializedHandler =
LSP.notificationHandler LSP.SInitialized $ const (return ())
initializedHandler = do
LSP.notificationHandler LSP.SInitialized $ \_params -> do
-- Register workspace watcher for src/ directory. This is used for checking
-- TS export lists.
--
-- This can fail if the client doesn't support dynamic registration for this:
-- in that case, we can't provide some features. See "Wasp.LSP.ExtImport" for
-- what features require this watcher.
watchSourceFilesToken <-
LSP.registerCapability
LSP.SWorkspaceDidChangeWatchedFiles
LSP.DidChangeWatchedFilesRegistrationOptions
{ _watchers =
LSP.List
[LSP.FileSystemWatcher {_globPattern = "**/*.{ts,tsx,js,jsx}", _kind = Nothing}]
}
watchSourceFilesHandler
case watchSourceFilesToken of
Nothing -> logM "[initializedHandler] Client did not accept WorkspaceDidChangeWatchedFiles registration"
Just _ -> logM "[initializedHandler] WorkspaceDidChangeWatchedFiles registered for JS/TS source files"
modify (State.regTokens . State.watchSourceFilesToken .~ watchSourceFilesToken)
-- | Ran when files in src/ change. It refreshes the relevant export lists in
-- the cache and updates missing import diagnostics.
--
-- Both of these tasks are ran in the reactor thread so that other requests
-- can still be answered.
watchSourceFilesHandler :: LSP.Handler ServerM 'LSP.WorkspaceDidChangeWatchedFiles
watchSourceFilesHandler msg = do
let (LSP.List uris) = fmap (^. LSP.uri) $ msg ^. LSP.params . LSP.changes
logM $ "[watchSourceFilesHandler] Received file changes: " ++ show uris
let fileUris = mapMaybe (SP.parseAbsFile <=< stripPrefix "file://" . T.unpack . LSP.getUri) uris
forM_ fileUris $ \file -> sendToReactor $ do
-- Refresh export list for modified file
refreshExportsForFiles [file]
-- Update diagnostics for the wasp file
updateMissingImportDiagnostics
handler $
asks (^. State.waspFileUri) >>= \case
Just uri -> do
logM $ "[watchSourceFilesHandler] Updating missing diagnostics for " ++ show uri
publishDiagnostics uri
Nothing -> pure ()
-- | Sent by the client when the client is going to shutdown the server, this
-- is where we do any clean up that needs to be done. This cleanup is:
-- - Stopping the reactor thread
shutdownHandler :: IO () -> Handlers ServerM
shutdownHandler stopReactor = LSP.requestHandler LSP.SShutdown $ \_ resp -> do
logM "Received shutdown request"
liftIO stopReactor
resp $ Right LSP.Empty
-- | "TextDocumentDidOpen" is sent by the client when a new document is opened.
-- `diagnoseWaspFile` is run to analyze the newly opened document.
@ -64,16 +130,22 @@ didSaveHandler =
completionHandler :: Handlers ServerM
completionHandler =
LSP.requestHandler LSP.STextDocumentCompletion $ \request respond -> do
completions <- getCompletionsAtPosition $ request ^. LSP.params . LSP.position
completions <- handler $ getCompletionsAtPosition $ request ^. LSP.params . LSP.position
respond $ Right $ LSP.InL $ LSP.List completions
gotoDefinitionHandler :: Handlers ServerM
gotoDefinitionHandler =
LSP.requestHandler LSP.STextDocumentDefinition $ \request respond -> do
definitions <- handler $ gotoDefinitionOfSymbolAtPosition $ request ^. LSP.params . LSP.position
respond $ Right $ LSP.InR $ LSP.InR definitions
signatureHelpHandler :: Handlers ServerM
signatureHelpHandler =
LSP.requestHandler LSP.STextDocumentSignatureHelp $ \request respond -> do
-- NOTE: lsp-types 1.4.0.1 forgot to add lenses for SignatureHelpParams so
-- we have to get the position out the painful way.
let LSP.SignatureHelpParams {_position = position} = request ^. LSP.params
signatureHelp <- getSignatureHelpAtPosition position
signatureHelp <- handler $ getSignatureHelpAtPosition position
respond $ Right signatureHelp
-- | Does not directly handle a notification or event, but should be run when
@ -85,28 +157,58 @@ signatureHelpHandler =
diagnoseWaspFile :: LSP.Uri -> ServerM ()
diagnoseWaspFile uri = do
analyzeWaspFile uri
currentDiagnostics <- gets (^. latestDiagnostics)
liftLSP $
LSP.sendNotification LSP.STextDocumentPublishDiagnostics $
LSP.PublishDiagnosticsParams uri Nothing (LSP.List currentDiagnostics)
-- Immediately update import diagnostics only when file watching is enabled
sourceWatchingEnabled <- isJust <$> handler (asks (^. State.regTokens . State.watchSourceFilesToken))
when sourceWatchingEnabled updateMissingImportDiagnostics
-- Send diagnostics to client
handler $ publishDiagnostics uri
-- Update exports and missing import diagnostics asynchronously. This is only
-- done if file watching is NOT enabled or if the export cache hasn't been
-- filled before.
exportCacheIsEmpty <- M.null <$> handler (asks (^. State.tsExports))
debouncer <- handler $ asks (^. State.debouncer)
when (not sourceWatchingEnabled || exportCacheIsEmpty) $
debounce debouncer 500000 State.RefreshExports $
sendToReactor $ do
refreshAllExports
updateMissingImportDiagnostics
handler $ publishDiagnostics uri
publishDiagnostics :: LSP.Uri -> HandlerM ()
publishDiagnostics uri = do
currentDiagnostics <- asks (^. latestDiagnostics)
srcString <- asks (^. currentWaspSource)
let lspDiagnostics = map (waspDiagnosticToLspDiagnostic srcString) currentDiagnostics
LSP.sendNotification
LSP.STextDocumentPublishDiagnostics
$ LSP.PublishDiagnosticsParams uri Nothing (LSP.List lspDiagnostics)
analyzeWaspFile :: LSP.Uri -> ServerM ()
analyzeWaspFile uri = do
srcString <- readAndStoreSourceString
let (concreteErrorMessages, concreteSyntax) = parseCST $ L.lex srcString
modify (cst ?~ concreteSyntax)
if not $ null concreteErrorMessages
then storeCSTErrors concreteErrorMessages
else runWaspAnalyzer srcString
modify (State.waspFileUri ?~ uri)
-- NOTE: we have to be careful to keep CST and source string in sync at all
-- times for all threads, so we update them both atomically (via one call to
-- 'modify').
readSourceString >>= \case
Nothing -> do
logM $ "Couldn't read source from VFS for wasp file " ++ show uri
pure ()
Just srcString -> do
let (concreteErrorMessages, concreteSyntax) = parseCST $ L.lex srcString
-- Atomic update of source string and CST
modify ((currentWaspSource .~ srcString) . (cst ?~ concreteSyntax))
if not $ null concreteErrorMessages
then storeCSTErrors concreteErrorMessages
else runWaspAnalyzer srcString
where
readAndStoreSourceString = do
srcString <- T.unpack <$> readVFSFile uri
modify (currentWaspSource .~ srcString)
return srcString
readSourceString = fmap T.unpack <$> readVFSFile uri
storeCSTErrors concreteErrorMessages = do
srcString <- gets (^. currentWaspSource)
newDiagnostics <- mapM (concreteParseErrorToDiagnostic srcString) concreteErrorMessages
let newDiagnostics = map ParseDiagnostic concreteErrorMessages
modify (latestDiagnostics .~ newDiagnostics)
runWaspAnalyzer srcString = do
@ -116,18 +218,14 @@ analyzeWaspFile uri = do
modify (latestDiagnostics .~ [])
Left err -> do
let newDiagnostics =
[ waspErrorToDiagnostic err
[ AnalyzerDiagonstic err
]
modify (latestDiagnostics .~ newDiagnostics)
-- | Read the contents of a "Uri" in the virtual file system maintained by the
-- LSP library.
readVFSFile :: LSP.Uri -> ServerM Text
readVFSFile uri = do
mVirtualFile <- liftLSP $ LSP.getVirtualFile $ LSP.toNormalizedUri uri
case mVirtualFile of
Just virtualFile -> return $ virtualFileText virtualFile
Nothing -> throwError $ ServerError Error $ "Could not find " <> T.pack (show uri) <> " in VFS."
readVFSFile :: LSP.Uri -> ServerM (Maybe Text)
readVFSFile uri = fmap LSP.virtualFileText <$> LSP.getVirtualFile (LSP.toNormalizedUri uri)
-- | Get the "Uri" from an object that has a "TextDocument".
extractUri :: (LSP.HasParams a b, LSP.HasTextDocument b c, LSP.HasUri c LSP.Uri) => a -> LSP.Uri

View File

@ -0,0 +1,47 @@
module Wasp.LSP.Reactor
( -- * Reactor Thread
-- To avoid long-running tasks blocking the main thread that serves responses
-- to the LSP client, these tasks are run on the \"reactor thread\". This
-- thread reacts to inputs sent on a 'TChan' and runs the corresponding IO
-- action.
ReactorInput (..),
reactor,
startReactorThread,
)
where
import Control.Concurrent (MVar, forkFinally, readMVar)
import Control.Concurrent.Async (async, waitAnyCancel)
import Control.Concurrent.STM (TChan, atomically, readTChan)
import Control.Monad (forever, void)
-- | An action sent to the reactor thread.
newtype ReactorInput = ReactorAction (IO ())
-- | Run the LSP reactor in the thread that runs this function. Reads actions
-- synchronously from the 'TChan' and executes them.
--
-- The reactor does not catch any error that occurs in the actions it runs.
reactor :: TChan ReactorInput -> IO ()
reactor rin = do
forever $ do
ReactorAction act <- atomically $ readTChan rin
act
-- | @startReactorThread lifetime rin@ spawns a thread that runs the reactor
-- and runs forever until it is told to stop, via @lifetime@ being filled.
--
-- When the reactor crashes, a new thread that runs the reactor is immediately
-- spawned.
startReactorThread :: MVar () -> TChan ReactorInput -> IO ()
startReactorThread lifetime rin = run
where
run = void $
forkFinally (runUntilMVarIsFull lifetime $ reactor rin) $ \case
Left _ -> run -- Restart reactor on crash.
Right () -> pure () -- Reactor ended peacefully, don't restart.
runUntilMVarIsFull :: MVar () -> IO () -> IO ()
runUntilMVarIsFull lifetime action =
void $ waitAnyCancel =<< traverse async [action, readMVar lifetime]

View File

@ -7,53 +7,77 @@ module Wasp.LSP.Server
)
where
import qualified Control.Concurrent.MVar as MVar
import Control.Concurrent (newEmptyMVar, tryPutMVar)
import Control.Concurrent.STM (newTChanIO, newTVarIO)
import Control.Monad (void)
import Control.Monad.IO.Class (MonadIO (liftIO))
import qualified Data.Aeson as Aeson
import Data.Default (Default (def))
import qualified Data.HashMap.Strict as M
import qualified Data.Text as Text
import qualified Language.LSP.Server as LSP
import qualified Language.LSP.Types as LSP
import System.Exit (ExitCode (ExitFailure), exitWith)
import qualified System.Log.Logger
import Wasp.LSP.Debouncer (newDebouncerIO)
import Wasp.LSP.Handlers
import Wasp.LSP.Reactor (startReactorThread)
import Wasp.LSP.ServerConfig (ServerConfig)
import Wasp.LSP.ServerM (ServerError (..), ServerM, Severity (..), runServerM)
import Wasp.LSP.ServerState (ServerState)
import Wasp.LSP.ServerM (ServerM, runRLspM)
import Wasp.LSP.ServerState
( RegistrationTokens (RegTokens, _watchSourceFilesToken),
ServerState (ServerState, _cst, _currentWaspSource, _debouncer, _latestDiagnostics, _reactorIn, _regTokens, _tsExports, _waspFileUri),
)
import Wasp.LSP.SignatureHelp (signatureHelpRetriggerCharacters, signatureHelpTriggerCharacters)
lspServerHandlers :: LSP.Handlers ServerM
lspServerHandlers =
lspServerHandlers :: IO () -> LSP.Handlers ServerM
lspServerHandlers stopReactor =
mconcat
[ initializedHandler,
shutdownHandler stopReactor,
didOpenHandler,
didSaveHandler,
didChangeHandler,
completionHandler,
signatureHelpHandler
signatureHelpHandler,
gotoDefinitionHandler
]
serve :: Maybe FilePath -> IO ()
serve maybeLogFile = do
setupLspLogger maybeLogFile
let defaultServerState = def :: ServerState
state <- MVar.newMVar defaultServerState
-- Reactor setup
reactorLifetime <- newEmptyMVar
let stopReactor = void $ tryPutMVar reactorLifetime ()
reactorIn <- newTChanIO
startReactorThread reactorLifetime reactorIn
-- Debouncer setup
debouncer <- newDebouncerIO
let defaultServerState =
ServerState
{ _waspFileUri = Nothing,
_currentWaspSource = "",
_latestDiagnostics = [],
_cst = Nothing,
_tsExports = M.empty,
_regTokens = RegTokens {_watchSourceFilesToken = Nothing},
_reactorIn = reactorIn,
_debouncer = debouncer
}
-- Create the TVar that manages the server state.
stateTVar <- newTVarIO defaultServerState
let lspServerInterpretHandler env =
LSP.Iso {forward = runHandler, backward = liftIO}
where
runHandler :: ServerM a -> IO a
runHandler handler =
-- Get the state from the "MVar", run the handler in IO and update
-- the "MVar" state with the end state of the handler.
MVar.modifyMVar state \oldState -> LSP.runLspT env $ do
(e, newState) <- runServerM oldState handler
result <- case e of
Left (ServerError severity errMessage) -> sendErrorMessage severity errMessage
Right a -> return a
return (newState, result)
LSP.runLspT env $ do
runRLspM stateTVar handler
exitCode <-
LSP.runServer
@ -61,7 +85,7 @@ serve maybeLogFile = do
{ defaultConfig = def :: ServerConfig,
onConfigurationChange = lspServerUpdateConfig,
doInitialize = lspServerDoInitialize,
staticHandlers = lspServerHandlers,
staticHandlers = lspServerHandlers stopReactor,
interpretHandler = lspServerInterpretHandler,
options = lspServerOptions
}
@ -124,26 +148,3 @@ syncOptions =
-- Send save notifications to the server.
_save = Just (LSP.InR (LSP.SaveOptions (Just True)))
}
-- | Send an error message to the LSP client.
--
-- Sends "Severity.Log" level errors to the output panel. Higher severity errors
-- are displayed in the window (i.e. in VSCode as a toast notification in the
-- bottom right).
sendErrorMessage :: Severity -> Text.Text -> LSP.LspT ServerConfig IO a
sendErrorMessage Log errMessage = do
let messageType = LSP.MtLog
LSP.sendNotification LSP.SWindowLogMessage $
LSP.LogMessageParams {_xtype = messageType, _message = errMessage}
liftIO (fail (Text.unpack errMessage))
sendErrorMessage severity errMessage = do
let messageType = case severity of
Error -> LSP.MtError
Warning -> LSP.MtWarning
Info -> LSP.MtInfo
Log -> LSP.MtLog
LSP.sendNotification LSP.SWindowShowMessage $
LSP.ShowMessageParams {_xtype = messageType, _message = errMessage}
liftIO (fail (Text.unpack errMessage))

View File

@ -1,63 +1,109 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Wasp.LSP.ServerM
( ServerM,
runServerM,
ServerError (..),
Severity (..),
liftLSP,
( -- * LSP Server Monads
-- The state of the LSP server is used in two different ways:
-- - Read only.
-- - Read and write.
--
-- Additionally, the state is accessed from multiple threads concurrently.
-- See waspls README for the architecture of the LSP server.
--
-- To facilitate this, there are two variants of the server monad: 'ServerM',
-- with write-access to the shared state via a 'TVar', and 'HandlerM' for
-- read-only access. In general, 'ServerM' should only be used in handlers
-- that are doing analysis on source files, that is, computing syntactic
-- and/or semantic information about the code that is needed for handlers to
-- respond to LSP requests.
--
-- For example, processing a @textDocumentDidChange@ notification runs in
-- 'ServerM' because it computes a new syntax tree for the wasp file,
-- whereas a @textDocumentcompletion@ request handler runs in 'HandlerM',
-- because it only needs to read from the latest analysis of the wasp file.
--
-- Under the hood, both monads are the 'RLspM' monad, distinguished only
-- by whether the context type is 'TVar' or not.
-- * Monads
RLspM,
ServerM,
HandlerM,
handler,
runRLspM,
-- * Operations
sendToReactor,
logM,
-- | You should usually use lenses for accessing the state.
--
-- __Examples:__
--
-- > import Control.Lens ((^.))
-- > gets (^. diagnostics) -- Gets the list of diagnostics
--
-- > import Control.Lens ((.~))
-- > modify (diagnostics .~ []) -- Clears diagnostics in the state
StateT.gets,
StateT.modify,
lift,
catchError,
throwError,
modify,
)
where
import Control.Monad.Error.Class (MonadError (catchError, throwError))
import Control.Monad.Except (ExceptT, runExceptT)
import Control.Concurrent.STM (TVar, atomically, modifyTVar, readTVarIO, writeTChan)
import Control.Lens ((^.))
import Control.Monad.IO.Unlift (MonadUnliftIO)
import Control.Monad.Log.Class (MonadLog (logM))
import Control.Monad.State.Class (MonadState)
import Control.Monad.State.Strict (StateT, runStateT)
import qualified Control.Monad.State.Strict as StateT
import Control.Monad.Trans (MonadIO (liftIO), lift)
import Data.Text (Text)
import Language.LSP.Server (LspT)
import Control.Monad.Reader (MonadReader (ask), ReaderT (ReaderT), asks, runReaderT)
import Control.Monad.Trans (MonadIO (liftIO))
import Language.LSP.Server (LspM, MonadLsp)
import qualified Language.LSP.Server as LSP
import qualified System.Log.Logger as L
import Wasp.LSP.Reactor (ReactorInput (ReactorAction))
import Wasp.LSP.ServerConfig (ServerConfig)
import Wasp.LSP.ServerState (ServerState)
import Wasp.LSP.ServerState (ServerState, reactorIn)
newtype ServerM a = ServerM
{ unServerM :: ExceptT ServerError (StateT ServerState (LspT ServerConfig IO)) a
-- | \"Reader LSP monad\": The LSP monad with a 'ReaderT' for extra state. Use
-- the type aliases 'ServerM' and 'HandlerM' instead of using this type directly.
newtype RLspM s a = RLspM
{ unServerM :: ReaderT s (LspM ServerConfig) a
}
deriving
( Functor,
Applicative,
Monad,
MonadError ServerError,
MonadState ServerState,
MonadIO
MonadReader s,
MonadIO,
MonadUnliftIO,
MonadLsp ServerConfig
)
runServerM ::
ServerState ->
ServerM a ->
LspT ServerConfig IO (Either ServerError a, ServerState)
runServerM state m = runStateT (runExceptT $ unServerM m) state
-- | 'RLspM' specialized to @'TVar' 'ServerState'@. This is how you can modify
-- the server state.
--
-- We use a reader with a 'TVar' instead of a state monad because we want to
-- be able to modify the state from other threads.
type ServerM = RLspM (TVar ServerState)
-- | Run a LSP function in the "ServerM" monad.
liftLSP :: LspT ServerConfig IO a -> ServerM a
liftLSP m = ServerM $ lift $ lift m
-- | Most LSP handlers should use this instead of 'ServerM', as there are only
-- limited places where modifying the state is needed.
type HandlerM = RLspM ServerState
-- | Run a 'HandlerM' in 'ServerM'.
handler :: HandlerM a -> ServerM a
handler act = RLspM $
ReaderT $ \stateTVar -> do
state <- liftIO $ readTVarIO stateTVar
runRLspM state act
-- | Modify the state inside the 'TVar' in the reader context.
modify :: (ServerState -> ServerState) -> ServerM ()
modify f = do
stateTVar <- ask
liftIO $ atomically $ modifyTVar stateTVar f
-- | Send a 'ServerM' action to the reactor thread.
sendToReactor :: ServerM () -> ServerM ()
sendToReactor act = do
stateTVar <- ask
env <- LSP.getLspEnv
rin <- handler $ asks (^. reactorIn)
liftIO $ atomically $ writeTChan rin $ ReactorAction $ LSP.runLspT env $ runRLspM stateTVar act
runRLspM ::
s ->
RLspM s a ->
LspM ServerConfig a
runRLspM state m = runReaderT (unServerM m) state
-- | Log a string.
--
@ -65,21 +111,5 @@ liftLSP m = ServerM $ lift $ lift m
-- logged messages will be displayed in the LSP client (e.g. for VSCode, in the
-- "Wasp Language Extension" output panel). Otherwise, it may be sent to a file
-- or not recorded at all.
instance MonadLog ServerM where
instance MonadLog (RLspM s) where
logM = liftIO . L.logM "haskell-lsp" L.DEBUG
-- | The type for a language server error. These are separate from diagnostics
-- and should be reported when the server fails to process a request/notification
-- for some reason.
data ServerError = ServerError Severity Text
-- | Error severity levels
data Severity
= -- | Displayed to user as an error
Error
| -- | Displayed to user as a warning
Warning
| -- | Displayed to user
Info
| -- | Not displayed to the user
Log

View File

@ -1,17 +1,37 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TemplateHaskell #-}
module Wasp.LSP.ServerState
( ServerState (..),
RegistrationTokens (..),
TsExportCache,
DebouncedEvents (..),
waspFileUri,
currentWaspSource,
latestDiagnostics,
cst,
tsExports,
regTokens,
watchSourceFilesToken,
reactorIn,
debouncer,
)
where
import Control.Concurrent.STM (TChan)
import Control.Lens (makeClassy)
import Data.Default (Default (def))
import qualified Data.HashMap.Strict as M
import Data.Hashable (Hashable)
import GHC.Generics (Generic)
import qualified Language.LSP.Server as LSP
import qualified Language.LSP.Types as LSP
import qualified StrongPath as SP
import Wasp.Analyzer.Parser.CST (SyntaxNode)
import Wasp.LSP.Debouncer (Debouncer)
import Wasp.LSP.Diagnostic (WaspDiagnostic)
import Wasp.LSP.Reactor (ReactorInput)
import Wasp.TypeScript (TsExport)
-- | LSP State preserved between handlers.
--
@ -20,20 +40,47 @@ import Wasp.Analyzer.Parser.CST (SyntaxNode)
--
-- Recommended to use the lenses for accessing the fields.
data ServerState = ServerState
{ -- | Source text for wasp file.
{ -- | Uri of main wasp file.
_waspFileUri :: Maybe LSP.Uri,
-- | Source text for wasp file.
_currentWaspSource :: String,
-- | List of diagnostics generated by waspc after the last file change.
_latestDiagnostics :: [LSP.Diagnostic],
_latestDiagnostics :: [WaspDiagnostic],
-- | Concrete syntax tree representing '_currentWaspSource'.
_cst :: Maybe [SyntaxNode]
_cst :: Maybe [SyntaxNode],
-- | Cache of source file export lists.
_tsExports :: TsExportCache,
-- | Registration tokens for dynamic capabilities.
_regTokens :: RegistrationTokens,
-- | Thread safe channel for sending actions to the LSP reactor thread.
_reactorIn :: TChan ReactorInput,
-- | See "Wasp.LSP.Debouncer".
_debouncer :: Debouncer DebouncedEvents
}
-- | Map from paths to JS/TS files to the list of exports from that file.
type TsExportCache = M.HashMap (SP.Path' SP.Abs SP.File') [TsExport]
-- | LSP dynamic capability registration tokens.
--
-- When a dynamic capability is registered, it returns a 'LSP.RegistrationToken'
-- which can be used to later unregister the capability.
-- See https://microsoft.github.io/language-server-protocol/specifications/specification-3-16/#client_registerCapability.
--
-- We also store these even when we aren't interested in unregistering because
-- we can use it to track whether the capability was registered or not (dynamic
-- registration can fail if the client doesn't support it).
data RegistrationTokens = RegTokens
{ -- | Token for the src/ directory file watcher.
_watchSourceFilesToken :: Maybe (LSP.RegistrationToken 'LSP.WorkspaceDidChangeWatchedFiles)
}
data DebouncedEvents
= RefreshExports
deriving (Eq, Show, Generic)
instance Hashable DebouncedEvents
makeClassy 'ServerState
instance Default ServerState where
def =
ServerState
{ _currentWaspSource = "",
_latestDiagnostics = [],
_cst = Nothing
}
makeClassy 'RegTokens

View File

@ -9,7 +9,7 @@ import Control.Applicative ((<|>))
import Control.Lens ((^.))
import Control.Monad (guard)
import Control.Monad.Log.Class (MonadLog, logM)
import Control.Monad.State.Class (MonadState, gets)
import Control.Monad.Reader.Class (MonadReader, asks)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe (MaybeT (runMaybeT))
import qualified Data.HashMap.Strict as M
@ -58,12 +58,12 @@ signatureHelpRetriggerCharacters = Just "}])"
-- The parameter field of the signature is used for which part of the container
-- the position is within, such as a key for a dictionary.
getSignatureHelpAtPosition ::
(MonadState ServerState m, MonadLog m) =>
(MonadReader ServerState m, MonadLog m) =>
LSP.Position ->
m LSP.SignatureHelp
getSignatureHelpAtPosition position = do
src <- gets (^. currentWaspSource)
gets (^. cst) >>= \case
src <- asks (^. currentWaspSource)
asks (^. cst) >>= \case
Nothing ->
-- No CST in the server state, can't create a signature.
return emptyHelp

View File

@ -1,8 +1,9 @@
module Wasp.LSP.CompletionTest where
import Control.Lens ((^.))
import Control.Monad (guard)
import Control.Monad.Log (runLog)
import Control.Monad.State.Strict (evalStateT, guard)
import Control.Monad.Reader (runReaderT)
import qualified Data.ByteString.Lazy as BS
import qualified Data.ByteString.Lazy.Char8 as BSC
import Data.List (elemIndex, isPrefixOf)
@ -16,7 +17,7 @@ import Text.Printf (printf)
import Wasp.Analyzer.Parser.ConcreteParser (parseCST)
import qualified Wasp.Analyzer.Parser.Lexer as Lexer
import Wasp.LSP.Completion (getCompletionsAtPosition)
import Wasp.LSP.ServerState (ServerState (ServerState, _cst, _currentWaspSource, _latestDiagnostics))
import Wasp.LSP.ServerState (ServerState (ServerState, _cst, _currentWaspSource, _debouncer, _latestDiagnostics, _reactorIn, _regTokens, _tsExports, _waspFileUri))
-- | A string containing the input to a completion test. It represents wasp
-- source code with a cursor position.
@ -98,11 +99,16 @@ runCompletionTest testInput =
parsedCST = snd $ parseCST tokens
serverState =
ServerState
{ _currentWaspSource = waspSource,
{ _waspFileUri = Nothing,
_currentWaspSource = waspSource,
_latestDiagnostics = [],
_cst = Just parsedCST
_cst = Just parsedCST,
_tsExports = error "_tsExports not available in completion tests",
_reactorIn = error "_reactorIn not available in completion tests",
_regTokens = error "_regTokens not available in completion tests",
_debouncer = error "_debouncer not available in completion tests"
}
(completionItems, _log) = runLog $ evalStateT (getCompletionsAtPosition cursorPosition) serverState
(completionItems, _log) = runLog $ runReaderT (getCompletionsAtPosition cursorPosition) serverState
fmtedCompletionItems = map fmtCompletionItem completionItems
fmtCompletionItem :: LSP.CompletionItem -> String

View File

@ -0,0 +1,54 @@
module Wasp.LSP.DebouncerTest
( spec_Debouncer,
)
where
import Control.Concurrent (newEmptyMVar, threadDelay, tryPutMVar, tryReadMVar)
import Control.Monad (replicateM_, void)
import GHC.Conc (atomically, newTVarIO, readTVar, readTVarIO, writeTVar)
import Test.Tasty.Hspec
import Wasp.LSP.Debouncer (debounce, newDebouncerIO)
spec_Debouncer :: Spec
spec_Debouncer = describe "Wasp.LSP.Debouncer" $ do
it "runs the action" $ do
debouncer <- newDebouncerIO
mvar <- newEmptyMVar
debounce debouncer 1000 () (void $ tryPutMVar mvar ())
threadDelay 20000
tryReadMVar mvar >>= (`shouldBe` Just ())
it "doesn't debounce actions for different events" $ do
debouncer <- newDebouncerIO
mvar1 <- newEmptyMVar
mvar2 <- newEmptyMVar
debounce debouncer 1000 'a' (void $ tryPutMVar mvar1 ())
debounce debouncer 1000 'b' (void $ tryPutMVar mvar2 ())
threadDelay 20000
tryReadMVar mvar1 >>= (`shouldBe` Just ())
tryReadMVar mvar2 >>= (`shouldBe` Just ())
it "debounces actions with the same event" $ do
debouncer <- newDebouncerIO
countTVar <- newTVarIO (0 :: Int)
replicateM_ 2 $
debounce debouncer 1000 () (atomically $ readTVar countTVar >>= (writeTVar countTVar . (+ 1)))
threadDelay 20000
readTVarIO countTVar >>= (`shouldBe` 1)
it "executes multiple actions from the same event given enough time" $ do
debouncer <- newDebouncerIO
countTVar <- newTVarIO (0 :: Int)
debounce debouncer 1000 () (atomically $ readTVar countTVar >>= (writeTVar countTVar . (+ 1)))
threadDelay 20000
debounce debouncer 1000 () (atomically $ readTVar countTVar >>= (writeTVar countTVar . (+ 1)))
threadDelay 20000
readTVarIO countTVar >>= (`shouldBe` 2)