mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-11-23 10:14:08 +03:00
[waspls] diagnostics for external imports and goto definition (#1268)
This commit is contained in:
parent
066b832127
commit
7c0d13d242
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
27
waspc/packages/README.md
Normal 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.
|
@ -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
2
waspc/packages/ts-inspect/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
26
waspc/packages/ts-inspect/README.md
Normal file
26
waspc/packages/ts-inspect/README.md
Normal 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" }
|
||||
]
|
||||
}
|
||||
```
|
83
waspc/packages/ts-inspect/eslintrc.cjs
Normal file
83
waspc/packages/ts-inspect/eslintrc.cjs
Normal 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"
|
||||
}
|
||||
}
|
6
waspc/packages/ts-inspect/jest.config.js
Normal file
6
waspc/packages/ts-inspect/jest.config.js
Normal 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
8236
waspc/packages/ts-inspect/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
waspc/packages/ts-inspect/package.json
Normal file
28
waspc/packages/ts-inspect/package.json
Normal 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"
|
||||
}
|
||||
}
|
101
waspc/packages/ts-inspect/src/exports.ts
Normal file
101
waspc/packages/ts-inspect/src/exports.ts
Normal 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 };
|
||||
}
|
||||
}
|
28
waspc/packages/ts-inspect/src/index.ts
Normal file
28
waspc/packages/ts-inspect/src/index.ts
Normal 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); });
|
3
waspc/packages/ts-inspect/test/exportTests/add.ts
Normal file
3
waspc/packages/ts-inspect/test/exportTests/add.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default function add(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
15
waspc/packages/ts-inspect/test/exportTests/complex.ts
Normal file
15
waspc/packages/ts-inspect/test/exportTests/complex.ts
Normal 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);
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export const isEven = (x: number) => {
|
||||
return (x % 2) === 0;
|
||||
}
|
12
waspc/packages/ts-inspect/test/exportTests/dict_export.ts
Normal file
12
waspc/packages/ts-inspect/test/exportTests/dict_export.ts
Normal 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
|
||||
};
|
0
waspc/packages/ts-inspect/test/exportTests/empty.ts
Normal file
0
waspc/packages/ts-inspect/test/exportTests/empty.ts
Normal file
3
waspc/packages/ts-inspect/test/exportTests/tsconfig.json
Normal file
3
waspc/packages/ts-inspect/test/exportTests/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
|
||||
}
|
100
waspc/packages/ts-inspect/test/exports.test.ts
Normal file
100
waspc/packages/ts-inspect/test/exports.test.ts
Normal 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 }
|
||||
}
|
||||
}]
|
||||
});
|
||||
})
|
||||
});
|
104
waspc/packages/ts-inspect/tsconfig.json
Normal file
104
waspc/packages/ts-inspect/tsconfig.json
Normal 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. */
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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')@
|
||||
|
@ -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
91
waspc/src/Wasp/Package.hs
Normal 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}
|
@ -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
|
||||
|
114
waspc/src/Wasp/TypeScript.hs
Normal file
114
waspc/src/Wasp/TypeScript.hs
Normal 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")
|
||||
)
|
@ -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
|
22
waspc/tools/install_packages_to_data_dir.sh
Executable file
22
waspc/tools/install_packages_to_data_dir.sh
Executable 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
|
@ -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
45
waspc/waspls/README.md
Normal 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`.
|
@ -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 []
|
||||
|
44
waspc/waspls/src/Wasp/LSP/Debouncer.hs
Normal file
44
waspc/waspls/src/Wasp/LSP/Debouncer.hs
Normal 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
|
@ -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
|
||||
|
301
waspc/waspls/src/Wasp/LSP/ExtImport.hs
Normal file
301
waspc/waspls/src/Wasp/LSP/ExtImport.hs
Normal 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'
|
79
waspc/waspls/src/Wasp/LSP/GotoDefinition.hs
Normal file
79
waspc/waspls/src/Wasp/LSP/GotoDefinition.hs
Normal 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}
|
@ -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
|
||||
|
47
waspc/waspls/src/Wasp/LSP/Reactor.hs
Normal file
47
waspc/waspls/src/Wasp/LSP/Reactor.hs
Normal 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]
|
@ -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))
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
54
waspc/waspls/test/Wasp/LSP/DebouncerTest.hs
Normal file
54
waspc/waspls/test/Wasp/LSP/DebouncerTest.hs
Normal 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)
|
Loading…
Reference in New Issue
Block a user