Merge branch 'main' into wasp-ai

This commit is contained in:
Filip Sodić 2023-07-12 17:44:24 +02:00
commit 4bff9069a3
87 changed files with 3368 additions and 631 deletions

View File

@ -83,6 +83,11 @@ jobs:
ghc --version
cabal --version
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Cache
uses: actions/cache@v3
with:
@ -107,7 +112,14 @@ jobs:
if: matrix.os == 'ubuntu-20.04'
run: ./run ormolu:check
- name: Compile deploy TS packages and move it into the Cabal data dir
- name: packages/ts-inspect - Run tests
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'macos-latest'
run: |
cd packages/ts-inspect
npm ci
npm test
- name: Compile TS packages and move it into the Cabal data dir
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'macos-latest'
run: ./tools/install_packages_to_data_dir.sh
@ -117,11 +129,6 @@ jobs:
- name: Build wasp code
run: cabal build all
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: On MacOS, skip e2e tests with Docker since it is not installed
if: matrix.os == 'macos-latest'
run: export WASP_E2E_TESTS_SKIP_DOCKER=1

View File

@ -94,9 +94,8 @@ This is the main repo of the Wasp universe, containing core code (mostly `waspc`
Currently, Wasp is in beta, with most features flushed out and working well.
However, there are still a lot of improvements and additions that we have in mind for the future, and we are working on them constantly, so you can expect a lot of changes and improvements in the future.
As Wasp grows further, it should allow the development of web apps of increasing complexity!
While the idea is to support multiple web tech stacks in the future, right now we are focusing on the specific stack: React + react-query, NodeJS + ExpressJS, Prisma. We might yet change that as time goes on, taking trends into account, but for now, this is serving us well to develop Wasp.
While the idea is to support multiple web tech stacks in the future, right now we are focusing on the specific stack: React + react-query, NodeJS + ExpressJS, Prisma.
# Contributing

View File

@ -1,5 +1,23 @@
# Changelog
## 0.11.1
### 🎉 [New feature] Prisma client preview flags
Wasp now allows you to enable desired `previewFeatures` for the Prisma client:
```
app MyApp {
title: "My app",
// ...
db: {
// ...
prisma: {
clientPreviewFeatures: ["extendedWhereUnique"]
}
}
}
```
Read all about Prisma preview features in [the official docs](https://www.prisma.io/docs/concepts/components/preview-features/client-preview-features).
## v0.11.0
### 🎉 Big new features 🎉

View File

@ -207,11 +207,9 @@ 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_packages_to_data_dir.sh`.
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.
`waspc`, while implemented in Haskell, for some of its functionality on TypeScript code (e.g. for parsing TS code, or for deployment scripts). In these cases, the Haskell code runs these TS packages as separate processes and communicates through input/output streams. These packages are located in `packages/` and are normal npm projects. See `packages/README.md` for how the projects are expected to be set up.
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.
To make these packages available to `waspc` in development, run `./run wasp-packages:compile`. When any changes are made to these packages, run the same command again.
## Tests
For tests we are using [**Tasty**](https://github.com/UnkindPartition/tasty) testing framework. Tasty let's us combine different types of tests into a single test suite.

View File

@ -8,6 +8,9 @@ datasource db {
generator client {
provider = "prisma-client-js"
output = {=& prismaClientOutputDir =}
{=# prismaPreviewFeatures =}
previewFeatures = {=& . =}
{=/ prismaPreviewFeatures =}
}
{=# modelSchemas =}

View File

@ -16,7 +16,7 @@ export function getLoginRoute({
args.email = args.email.toLowerCase()
const user = await findUserBy<'email'>({ email: args.email })
const user = await findUserBy({ email: args.email })
if (!user) {
throwInvalidCredentialsError()
}

View File

@ -28,7 +28,7 @@ export function getRequestPasswordResetRoute({
args.email = args.email.toLowerCase();
const user = await findUserBy<'email'>({ email: args.email });
const user = await findUserBy({ email: args.email });
// User not found or not verified - don't leak information
if (!user || !user.isEmailVerified) {

View File

@ -12,7 +12,7 @@ export async function resetPassword(
const { token, password } = args;
try {
const { id: userId } = await verifyToken(token);
const user = await findUserBy<'id'>({ id: userId });
const user = await findUserBy({ id: userId });
if (!user) {
return res.status(400).json({ success: false, message: 'Invalid token' });
}

View File

@ -30,7 +30,7 @@ export function getSignupRoute({
userFields.email = userFields.email.toLowerCase();
const existingUser = await findUserBy<'email'>({ email: userFields.email });
const existingUser = await findUserBy({ email: userFields.email });
// User already exists and is verified - don't leak information
if (existingUser && existingUser.isEmailVerified) {
await doFakeWork();

View File

@ -7,7 +7,7 @@ import { findUserBy, createAuthToken } from '../../utils.js'
export default handleRejection(async (req, res) => {
const args = req.body || {}
const user = await findUserBy<'username'>({ username: args.username })
const user = await findUserBy({ username: args.username })
if (!user) {
throwInvalidCredentialsError()
}

View File

@ -26,7 +26,7 @@ export const authConfig = {
successRedirectPath: "{= successRedirectPath =}",
}
export async function findUserBy<K extends keyof {= userEntityUpper =}>(where: { [key in K]: {= userEntityUpper =}[K] }): Promise<{= userEntityUpper =}> {
export async function findUserBy(where: Prisma.{= userEntityUpper =}WhereUniqueInput): Promise<{= userEntityUpper =}> {
return prisma.{= userEntityLower =}.findUnique({ where });
}

View File

@ -1,7 +1,7 @@
app waspBuild {
db: { system: PostgreSQL },
wasp: {
version: "^0.11.0"
version: "^0.11.1"
},
title: "waspBuild"
}

View File

@ -1,6 +1,6 @@
app waspCompile {
wasp: {
version: "^0.11.0"
version: "^0.11.1"
},
title: "waspCompile"
}

View File

@ -186,7 +186,7 @@
"file",
"server/src/auth/utils.ts"
],
"4b57bc76321dbc5708ae28aeffb6e6402e06517221e2044cf3712b16e124a4ff"
"b611de9a6b546f6f1cec4497a4cb525150399131dfba32b53ba34afb04e62e96"
],
[
[

View File

@ -20,7 +20,7 @@ export const authConfig = {
successRedirectPath: "/",
}
export async function findUserBy<K extends keyof User>(where: { [key in K]: User[K] }): Promise<User> {
export async function findUserBy(where: Prisma.UserWhereUniqueInput): Promise<User> {
return prisma.user.findUnique({ where });
}

View File

@ -1,7 +1,7 @@
app waspComplexTest {
db: { system: PostgreSQL },
wasp: {
version: "^0.11.0"
version: "^0.11.1"
},
auth: {
userEntity: User,

View File

@ -1,7 +1,7 @@
app waspJob {
db: { system: PostgreSQL },
wasp: {
version: "^0.11.0"
version: "^0.11.1"
},
title: "waspJob"
}

View File

@ -1,6 +1,6 @@
app waspMigrate {
wasp: {
version: "^0.11.0"
version: "^0.11.1"
},
title: "waspMigrate"
}

View File

@ -1,6 +1,6 @@
app waspNew {
wasp: {
version: "^0.11.0"
version: "^0.11.1"
},
title: "waspNew"
}

View File

@ -18,8 +18,8 @@ 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.
The last line assumes the project is compiled to `.js` files inside the `dist`
directory. You should adjust this and/or add more file extensions if needed.
# CI Builds/Release

View File

@ -1,8 +1,6 @@
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:
This package provides a command-line interface for getting information about
exported symbols from JS/TS files. As input you give it a list of exports requests,
each containing a list of filepaths and, optionally, a path to a tsconfig file.
```json
[
@ -14,7 +12,12 @@ stdin:
]
```
It will respond with an object mapping filenames to exports, something like:
Note that an instance of the TypeScript compiler is created for each exports
request, so grouping all files with the same tsconfig into one request confers
some performance benefit.
The program responds with a list of exports for each file. The filepaths in the
result will always exactly match the filepaths in the input:
```json
{

View File

@ -11,6 +11,10 @@
"start": "node ./dist/index.js",
"test": "jest"
},
"//": [
"typescript is purposefully a normal dependency, and not a dev dependency:",
"the compiler is called at runtime to extract information from JS/TS files."
],
"dependencies": {
"json5": "^2.2.3",
"typescript": "^5.1.3",

View File

@ -4,14 +4,14 @@ import * as path from 'path';
import JSON5 from 'json5';
import { z } from 'zod';
export const ExportRequest = z.object({
export const ExportsRequest = z.object({
tsconfig: z.string().optional(),
filenames: z.array(z.string())
filepaths: z.array(z.string())
});
export const ExportRequests = z.array(ExportRequest);
export const ExportsRequests = z.array(ExportsRequest);
export type ExportRequest = z.infer<typeof ExportRequest>;
export type ExportsRequest = z.infer<typeof ExportsRequest>;
export type Export
= { type: 'default' } & Range
@ -21,33 +21,24 @@ 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[] }> {
export async function getExportsOfFiles(request: ExportsRequest): 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;
compilerOptions = await loadCompilerOptionsFromTsConfig(request.tsconfig);
}
const exportsMap: { [file: string]: Export[] } = {};
// Initialize the TS compiler.
const program = ts.createProgram(request.filenames, compilerOptions);
const program = ts.createProgram(request.filepaths, compilerOptions);
const checker = program.getTypeChecker();
// Loop through each given file and try to get its exports.
for (let filename of request.filenames) {
for (let filename of request.filepaths) {
try {
exportsMap[filename] = getExportsForFile(program, checker, filename);
exportsMap[filename] = getExportsOfFile(program, checker, filename);
} catch (err) {
console.error(err);
exportsMap[filename] = [];
@ -57,7 +48,20 @@ export async function getExportsOfFiles(request: ExportRequest): Promise<{ [file
return exportsMap;
}
function getExportsForFile(program: ts.Program, checker: ts.TypeChecker, filename: string): Export[] {
async function loadCompilerOptionsFromTsConfig(tsconfig: string): Promise<ts.CompilerOptions> {
const configJson = JSON5.parse(await fs.readFile(tsconfig, 'utf8'));
const basePath = path.dirname(tsconfig)
const { options, errors } = ts.convertCompilerOptionsFromJson(
configJson.compilerOptions, basePath, tsconfig
);
if (errors && errors.length) {
throw errors;
}
return options;
}
function getExportsOfFile(program: ts.Program, checker: ts.TypeChecker, filename: string): Export[] {
const source = program.getSourceFile(filename);
if (!source) {
throw new Error(`Error getting source for ${filename}`);
@ -67,23 +71,26 @@ function getExportsForFile(program: ts.Program, checker: ts.TypeChecker, filenam
// 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));
const exportSymbols = checker.getExportsOfModule(moduleSymbol);
return exportSymbols.map(exp => getExportForExportSymbol(program, checker, exp));
}
function getExportForExportSymbol(program: ts.Program, checker: ts.TypeChecker, exp: ts.Symbol): Export {
function getExportForExportSymbol(program: ts.Program, checker: ts.TypeChecker, exportSymbol: ts.Symbol): Export {
let range = undefined;
if (exp.valueDeclaration) {
// Try to get the location information from the first value declaration for
// the export. If there are no value declarations, we don't return any location
// info for this export and `range` remains undefined.
if (exportSymbol.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 startOffset = exportSymbol.valueDeclaration.getStart();
const startPos = ts.getLineAndCharacterOfPosition(
exp.valueDeclaration.getSourceFile(), startOffset
exportSymbol.valueDeclaration.getSourceFile(), startOffset
);
const endOffset = exp.valueDeclaration.getEnd();
const endOffset = exportSymbol.valueDeclaration.getEnd();
const endPos = ts.getLineAndCharacterOfPosition(
exp.valueDeclaration.getSourceFile(), endOffset
exportSymbol.valueDeclaration.getSourceFile(), endOffset
)
range = {
start: { line: startPos.line, column: startPos.character },
@ -92,7 +99,7 @@ function getExportForExportSymbol(program: ts.Program, checker: ts.TypeChecker,
}
// Convert export to the output format.
const exportName = exp.getName();
const exportName = exportSymbol.getName();
if (exportName === 'default') {
return { type: 'default', range };
} else {

View File

@ -1,6 +1,6 @@
import { ExportRequests, getExportsOfFiles } from "./exports.js";
import { ExportsRequests, getExportsOfFiles } from "./exports.js";
async function readStdin(): Promise<string> {
async function readAllFromStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let chunks = '';
process.stdin.on('data', (data) => {
@ -13,9 +13,9 @@ async function readStdin(): Promise<string> {
}
async function main() {
const inputStr = await readStdin();
const inputStr = await readAllFromStdin();
const input = JSON.parse(inputStr);
const requests = ExportRequests.parse(input);
const requests = ExportsRequests.parse(input);
let exports = {};
for (let request of requests) {

View File

@ -1,34 +1,36 @@
import * as path from 'path';
import { getExportsOfFiles } from "../src/exports";
// TODO(before merge): run these tests in CI
/**
* Get an absolute path to a test file
* @param filename Name of test file inside __dirname/exportTests directory
*/
function testFile(filename: string): string {
function getTestFilePath(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'),
emptyFile: getTestFilePath('empty.ts'),
addFile: getTestFilePath('add.ts'),
complexFile: getTestFilePath('complex.ts'),
dictExportFile: getTestFilePath('dict_export.ts'),
constExportFile: getTestFilePath('const_export.ts'),
emptyTsconfig: testFile('tsconfig.json'),
emptyTsconfig: getTestFilePath('tsconfig.json'),
};
describe('exports.ts', () => {
test('empty ts file has empty exports', async () => {
const request = { filenames: [testFiles.emptyFile] };
const request = { filepaths: [testFiles.emptyFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.emptyFile]: []
});
});
test('add file has just a default export', async () => {
const request = { filenames: [testFiles.addFile] };
const request = { filepaths: [testFiles.addFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.addFile]: [{
type: 'default',
@ -41,7 +43,7 @@ describe('exports.ts', () => {
});
test('complex file has default and normal export', async () => {
const request = { filenames: [testFiles.complexFile] };
const request = { filepaths: [testFiles.complexFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.complexFile]: [
{
@ -70,7 +72,7 @@ describe('exports.ts', () => {
});
test('dict_export file shows names for each export in dict', async () => {
const request = { filenames: [testFiles.dictExportFile] };
const request = { filepaths: [testFiles.dictExportFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.dictExportFile]: [
{ type: 'named', name: 'add' },
@ -80,14 +82,14 @@ describe('exports.ts', () => {
});
test('empty ts file works with empty tsconfig', async () => {
const request = { filenames: [testFiles.emptyFile], tsconfig: testFiles.emptyTsconfig };
const request = { filepaths: [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] };
const request = { filepaths: [testFiles.constExportFile] };
expect(await getExportsOfFiles(request)).toEqual({
[testFiles.constExportFile]: [{
type: 'named', name: 'isEven', range: {

View File

@ -3,6 +3,7 @@
module Wasp.AppSpec.App.Db
( Db (..),
DbSystem (..),
PrismaOptions (..),
)
where
@ -11,9 +12,15 @@ import Wasp.AppSpec.ExtImport (ExtImport)
data Db = Db
{ system :: Maybe DbSystem,
seeds :: Maybe [ExtImport]
seeds :: Maybe [ExtImport],
prisma :: Maybe PrismaOptions
}
deriving (Show, Eq, Data)
data DbSystem = PostgreSQL | SQLite
deriving (Show, Eq, Data)
data PrismaOptions = PrismaOptions
{ clientPreviewFeatures :: Maybe [String]
}
deriving (Show, Eq, Data)

View File

@ -67,7 +67,8 @@ genPrismaSchema spec = do
[ "modelSchemas" .= map entityToPslModelSchema (AS.getDecls @AS.Entity.Entity spec),
"datasourceProvider" .= datasourceProvider,
"datasourceUrl" .= datasourceUrl,
"prismaClientOutputDir" .= makeEnvVarField Wasp.Generator.DbGenerator.Common.prismaClientOutputDirEnvVar
"prismaClientOutputDir" .= makeEnvVarField Wasp.Generator.DbGenerator.Common.prismaClientOutputDirEnvVar,
"prismaPreviewFeatures" .= prismaPreviewFeatures
]
return $ createTemplateFileDraft Wasp.Generator.DbGenerator.Common.dbSchemaFileInProjectRootDir tmplSrcPath (Just templateData)
@ -75,6 +76,7 @@ genPrismaSchema spec = do
tmplSrcPath = Wasp.Generator.DbGenerator.Common.dbTemplatesDirInTemplatesDir </> Wasp.Generator.DbGenerator.Common.dbSchemaFileInDbTemplatesDir
dbSystem = fromMaybe AS.Db.SQLite $ AS.Db.system =<< AS.App.db (snd $ getApp spec)
makeEnvVarField envVarName = "env(\"" ++ envVarName ++ "\")"
prismaPreviewFeatures = show <$> (AS.Db.clientPreviewFeatures =<< AS.Db.prisma =<< AS.App.db (snd $ getApp spec))
entityToPslModelSchema :: (String, AS.Entity.Entity) -> String
entityToPslModelSchema (entityName, entity) =

View File

@ -1,9 +1,13 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE TypeApplications #-}
module Wasp.Package
( Package (..),
getPackageProc,
module Wasp.NodePackageFFI
( -- * Node Package FFI
-- Provides utilities for setting up and running node processes from the
-- @packages/@ directory.
Package (..),
getPackageProcessOptions,
)
where
@ -51,10 +55,11 @@ scriptInPackageDir = [relfile|dist/index.js|]
-- 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
-- If the package does not have its dependencies installed yet (for example,
-- when the package is run for the first time after installing Wasp), we install
-- the dependencies.
getPackageProcessOptions :: Package -> [String] -> IO P.CreateProcess
getPackageProcessOptions package args = do
getAndCheckNodeVersion >>= \case
Right _ -> pure ()
Left errorMsg -> do
@ -64,7 +69,7 @@ getPackageProc package args = do
packageDir <- getPackageDir package
let scriptFile = packageDir </> scriptInPackageDir
ensurePackageDependenciesAreInstalled packageDir
return $ packageProc packageDir "node" (fromAbsFile scriptFile : args)
return $ packageCreateProcess packageDir "node" (fromAbsFile scriptFile : args)
getPackageDir :: Package -> IO (Path' Abs (Dir PackageDir))
getPackageDir package = do
@ -76,7 +81,7 @@ getPackageDir package = do
ensurePackageDependenciesAreInstalled :: Path' Abs (Dir PackageDir) -> IO ()
ensurePackageDependenciesAreInstalled packageDir =
unlessM nodeModulesDirExists $ do
let npmInstallCreateProcess = packageProc packageDir "npm" ["install"]
let npmInstallCreateProcess = packageCreateProcess packageDir "npm" ["install"]
(exitCode, _out, err) <- P.readCreateProcessWithExitCode npmInstallCreateProcess ""
case exitCode of
ExitFailure _ -> do
@ -92,9 +97,9 @@ ensurePackageDependenciesAreInstalled packageDir =
--
-- NOTE: do not export this function! users of this module should have to go
-- through 'getPackageProc', which makes sure node_modules are present.
packageProc ::
packageCreateProcess ::
Path' Abs (Dir PackageDir) ->
String ->
[String] ->
P.CreateProcess
packageProc packageDir cmd args = (P.proc cmd args) {P.cwd = Just $ fromAbsDir packageDir}
packageCreateProcess packageDir cmd args = (P.proc cmd args) {P.cwd = Just $ fromAbsDir packageDir}

View File

@ -11,7 +11,7 @@ import StrongPath (Abs, Dir, Path', relfile, toFilePath, (</>))
import System.Directory (doesFileExist)
import System.Exit (ExitCode (..))
import qualified System.Process as P
import Wasp.Package (Package (DeployPackage), getPackageProc)
import Wasp.NodePackageFFI (Package (DeployPackage), getPackageProcessOptions)
import Wasp.Project.Common (WaspProjectDir)
loadUserDockerfileContents :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe Text)
@ -28,7 +28,7 @@ deploy ::
IO (Either String ())
deploy waspExe waspDir cmdArgs = do
let deployScriptArgs = concat [cmdArgs, ["--wasp-exe", waspExe, "--wasp-project-dir", toFilePath waspDir]]
cp <- getPackageProc DeployPackage deployScriptArgs
cp <- getPackageProcessOptions 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,

View File

@ -1,7 +1,7 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Wasp.TypeScript
module Wasp.TypeScript.Inspect.Exports
( -- * Getting Information About TypeScript Files
-- Internally, this module calls out to @packages/ts-inspect@, which uses
@ -12,8 +12,8 @@ module Wasp.TypeScript
-- * Export lists
getExportsOfTsFiles,
TsExportRequest (..),
TsExportResponse (..),
TsExportsRequest (..),
TsExportsResponse (..),
TsExport (..),
tsExportSourceRegion,
)
@ -28,16 +28,16 @@ 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)
import Wasp.NodePackageFFI (Package (TsInspectPackage), getPackageProcessOptions)
-- | 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 :: [TsExportsRequest] -> IO (Either String TsExportsResponse)
getExportsOfTsFiles requests = do
let requestJSON = BS.toString $ encode $ groupExportRequests requests
cp <- getPackageProc TsInspectPackage []
let requestJSON = BS.toString $ encode $ groupExportRequestsByTsconfig requests
cp <- getPackageProcessOptions TsInspectPackage []
(exitCode, response, err) <- P.readCreateProcessWithExitCode cp requestJSON
case exitCode of
ExitSuccess -> case decode $ BS.fromString response of
@ -45,15 +45,15 @@ getExportsOfTsFiles requests = do
Just exports -> return $ Right exports
_ -> return $ Left err
-- | Join export requests that have the same tsconfig. The @ts-inspect@ package
-- | Join exports 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) $
groupExportRequestsByTsconfig :: [TsExportsRequest] -> [TsExportsRequest]
groupExportRequestsByTsconfig requests =
map (uncurry $ flip TsExportsRequest) $
M.toList $ foldr insertRequest M.empty requests
where
insertRequest (TsExportRequest names maybeTsconfig) grouped =
insertRequest (TsExportsRequest names maybeTsconfig) grouped =
M.insertWith (++) maybeTsconfig names grouped
-- | A symbol exported from a TypeScript file.
@ -80,15 +80,15 @@ instance FromJSON TsExport where
(_ :: 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])
newtype TsExportsResponse = TsExportsResponse (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)}
data TsExportsRequest = TsExportsRequest {filepaths :: ![FilePath], tsconfig :: !(Maybe FilePath)}
deriving (Eq, Show, Generic)
instance ToJSON TsExportRequest where
instance ToJSON TsExportsRequest where
toEncoding = genericToEncoding defaultOptions
-- Wrapper types for parsing SourceRegions from data with 0-based offsets.

View File

@ -63,7 +63,10 @@ spec_Analyzer = do
" },",
" db: {",
" system: PostgreSQL,",
" seeds: [ import { devSeedSimple } from \"@server/dbSeeds.js\" ]",
" seeds: [ import { devSeedSimple } from \"@server/dbSeeds.js\" ],",
" prisma: {",
" clientPreviewFeatures: [\"extendedWhereUnique\"]",
" }",
" },",
" emailSender: {",
" provider: SendGrid,",
@ -174,7 +177,12 @@ spec_Analyzer = do
[ ExtImport
(ExtImportField "devSeedSimple")
(fromJust $ SP.parseRelFileP "dbSeeds.js")
]
],
Db.prisma =
Just
Db.PrismaOptions
{ clientPreviewFeatures = Just ["extendedWhereUnique"]
}
},
App.emailSender =
Just

View File

@ -6,7 +6,7 @@ cabal-version: 2.4
-- Consider using hpack, or maybe even hpack-dhall.
name: waspc
version: 0.11.0
version: 0.11.1
description: Please see the README on GitHub at <https://github.com/wasp-lang/wasp/waspc#readme>
homepage: https://github.com/wasp-lang/wasp/waspc#readme
bug-reports: https://github.com/wasp-lang/wasp/issues
@ -329,7 +329,7 @@ library
Wasp.Message
Wasp.Node.Version
Wasp.NpmDependency
Wasp.Package
Wasp.NodePackageFFI
Wasp.Project
Wasp.Project.Analyze
Wasp.Project.Common
@ -346,7 +346,7 @@ library
Wasp.Psl.Parser.Model
Wasp.Psl.Util
Wasp.SemanticVersion
Wasp.TypeScript
Wasp.TypeScript.Inspect.Exports
Wasp.Util
Wasp.Util.Aeson
Wasp.Util.Control.Monad
@ -366,20 +366,25 @@ 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.Analysis
Wasp.LSP.Completion
Wasp.LSP.GotoDefinition
Wasp.LSP.Reactor
Wasp.LSP.Completions.Common
Wasp.LSP.Completions.DictKeyCompletion
Wasp.LSP.Completions.ExprCompletion
Wasp.LSP.Debouncer
Wasp.LSP.Diagnostic
Wasp.LSP.DynamicHandlers
Wasp.LSP.ExtImport.Diagnostic
Wasp.LSP.ExtImport.ExportsCache
Wasp.LSP.ExtImport.Path
Wasp.LSP.ExtImport.Syntax
Wasp.LSP.GotoDefinition
Wasp.LSP.Handlers
Wasp.LSP.Reactor
Wasp.LSP.Server
Wasp.LSP.ServerConfig
Wasp.LSP.ServerMonads
Wasp.LSP.ServerState
Wasp.LSP.SignatureHelp
Wasp.LSP.Syntax
Wasp.LSP.TypeInference

View File

@ -0,0 +1,102 @@
module Wasp.LSP.Analysis
( diagnoseWaspFile,
publishDiagnostics,
)
where
import Control.Lens ((.~), (?~), (^.))
import Control.Monad (when)
import Control.Monad.Log.Class (logM)
import Control.Monad.Reader.Class (asks)
import qualified Data.HashMap.Strict as M
import Data.Maybe (isJust)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Language.LSP.Server as LSP
import qualified Language.LSP.Types as LSP
import qualified Language.LSP.VFS as LSP
import Wasp.Analyzer (analyze)
import Wasp.Analyzer.Parser.ConcreteParser (parseCST)
import qualified Wasp.Analyzer.Parser.Lexer as L
import Wasp.LSP.Debouncer (debounce)
import Wasp.LSP.Diagnostic (WaspDiagnostic (AnalyzerDiagnostic, ParseDiagnostic), waspDiagnosticToLspDiagnostic)
import Wasp.LSP.ExtImport.Diagnostic (updateMissingExtImportDiagnostics)
import Wasp.LSP.ExtImport.ExportsCache (refreshExportsForAllExtImports)
import Wasp.LSP.ServerMonads (HandlerM, ServerM, handler, modify, sendToReactor)
import qualified Wasp.LSP.ServerState as State
-- | Finds diagnostics on a wasp file and sends the diagnostics to the LSP
-- client.
diagnoseWaspFile :: LSP.Uri -> ServerM ()
diagnoseWaspFile uri = do
analyzeWaspFile uri
-- Immediately update import diagnostics only when file watching is enabled
sourceWatchingEnabled <- isJust <$> handler (asks (^. State.regTokens . State.watchSourceFilesToken))
when sourceWatchingEnabled updateMissingExtImportDiagnostics
-- 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
refreshExportsForAllExtImports
updateMissingExtImportDiagnostics
handler $ publishDiagnostics uri
-- | Send diagnostics stored in 'State.latestDiagnostics' to the LSP client.
publishDiagnostics :: LSP.Uri -> HandlerM ()
publishDiagnostics uri = do
currentDiagnostics <- asks (^. State.latestDiagnostics)
srcString <- asks (^. State.currentWaspSource)
let lspDiagnostics = map (waspDiagnosticToLspDiagnostic srcString) currentDiagnostics
LSP.sendNotification
LSP.STextDocumentPublishDiagnostics
$ LSP.PublishDiagnosticsParams uri Nothing (LSP.List lspDiagnostics)
-- | Run wasp Analyzer on a file and replace the diagnostics in 'State.latestDiagnostics'
-- with the diagnostics reported by the Analyzer.
analyzeWaspFile :: LSP.Uri -> ServerM ()
analyzeWaspFile uri = do
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 ((State.currentWaspSource .~ srcString) . (State.cst ?~ concreteSyntax))
if not $ null concreteErrorMessages
then storeCSTErrors concreteErrorMessages
else runWaspAnalyzer srcString
where
readSourceString = fmap T.unpack <$> readVFSFile uri
storeCSTErrors concreteErrorMessages = do
let newDiagnostics = map ParseDiagnostic concreteErrorMessages
modify (State.latestDiagnostics .~ newDiagnostics)
runWaspAnalyzer srcString = do
let analyzeResult = analyze srcString
case analyzeResult of
Right _ -> do
modify (State.latestDiagnostics .~ [])
Left errs -> do
let newDiagnostics = fmap AnalyzerDiagnostic errs
modify (State.latestDiagnostics .~ newDiagnostics)
-- | Read the contents of a "Uri" in the virtual file system maintained by the
-- LSP library.
readVFSFile :: LSP.Uri -> ServerM (Maybe Text)
readVFSFile uri = fmap LSP.virtualFileText <$> LSP.getVirtualFile (LSP.toNormalizedUri uri)

View File

@ -1,5 +1,15 @@
module Wasp.LSP.Debouncer
( Debouncer,
( -- * Smoothing Noisy Events
-- Provides a debouncing API events that get triggered repeatedly in a
-- short window of time. Debouncing these events treats these repeated
-- triggers as a single firing of the event.
--
-- For example, if we want to do something when the user finishes editing
-- a file, we may look typing events. But the user will type many times
-- to make a single logical edit, so we want to consider all of those
-- individual typing events as one big typing event.
Debouncer,
newDebouncerIO,
debounce,
)
@ -14,31 +24,75 @@ import Data.Foldable (traverse_)
import Data.Hashable (Hashable)
import qualified StmContainers.Map as STM
-- | @debounce debouncer waitMicros event fire@ prevents certain actions from
-- running too often by ignoring repeated attempts to run the action within a
-- certain period of time. This is done by \"debouncing\" actions with the same
-- @event@, which is a label for what is triggering this action to run.
--
-- When 'debounce' is called, it waits @waitMicros@ microseconds before running
-- the action @fire@. If 'debounce' is called again within @waitMicros@
-- microseconds, the previous action is cancelled and only @fire@ from the newer
-- call runs. The new call waits another @waitMicros@ and can also be cancelled.
--
-- Use this function to reduce the number of times some action is run. For example,
-- refreshing the list of exported symbols from TS files on file change is debounced,
-- since we don't want to do that after every key press in the editor.
--
-- ==== __Example__
-- @
-- do
-- debouncer <- newDebouncerIO
-- debounce debouncer 1000 "event a" $ putStrLn "Event A #1"
-- threadDelay 100
-- debounce debouncer 1000 "event a" $ putStrLn "Event A #2"
-- threadDelay 2000
-- @
-- Prints just @Event A #2@.
--
-- ==== __Example__
-- @
-- do
-- debouncer <- newDebouncerIO
-- debounce debouncer 1000 "event a" $ putStrLn "Event A #1"
-- threadDelay 100
-- debounce debouncer 1000 "event b" $ putStrLn "Event B #1"
-- threadDelay 2000
-- @
-- Prints both @Event A #1@ and @Event B #1@.
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
-- Spawn a new thread that waits @waitMicros@ before running @fireIO@.
newDelayedAction <- liftIO $
async $ do
threadDelay waitMicros
fireIO
-- Mark this event as inactive by removing it from the running events.
atomically $ STM.delete event running
-- Atomically replace the previous action for this event (if any) with
-- @newDelayedAction@.
prevDelayedAction <- liftIO $
atomically $ do
prevAction <- STM.lookup event running
STM.insert newDelayedAction event running
return prevAction
-- Cancel the previous action for this event (if any).
liftIO $ traverse_ cancel prevDelayedAction
-- | 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 ()))
newtype Debouncer k = Debouncer
{ -- | A thread-safe map of event labels to async actions. This stores
-- actions for each event that are waiting for their debounce timers to end.
-- This is needed so that new calls to 'debounce' can cancel the currently
-- running actions before they can finish running.
debouncerRunningEvents :: STM.Map k (Async ())
}
newDebouncerIO :: IO (Debouncer k)
newDebouncerIO = Debouncer <$> STM.newIO
-- | @debounce debouncer waitMicros event action@ waits @waitMicros@ microseconds
-- and then runs @action@.
--
-- If 'debounce' is called again with the same @event@, only the newer call
-- fires.
debounce :: (MonadUnliftIO m, MonadIO m, Eq k, Hashable k) => Debouncer k -> Int -> k -> m () -> m ()
debounce (Debouncer running) waitMicros event fire = do
fireIO <- toIO fire
a <- liftIO $
async $ do
threadDelay waitMicros
fireIO
atomically $ STM.delete event running
prev <- liftIO $
atomically $ do
prev <- STM.lookup event running
STM.insert a event running
return prev
liftIO $ traverse_ cancel prev

View File

@ -1,15 +1,14 @@
module Wasp.LSP.Diagnostic
( WaspDiagnostic (..),
MissingImportReason (..),
MissingExtImportReason (..),
waspDiagnosticToLspDiagnostic,
clearMissingImportDiagnostics,
clearMissingExtImportDiagnostics,
)
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
@ -17,32 +16,33 @@ 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.ExtImport.Path (WaspStyleExtFilePath (WaspStyleExtFilePath))
import Wasp.LSP.Util (waspSourceRegionToLspRange)
data WaspDiagnostic
= ParseDiagnostic !CPE.ParseError
| AnalyzerDiagonstic !W.AnalyzeError
| MissingImportDiagnostic !SourceSpan !MissingImportReason !(SP.Path' SP.Abs SP.File')
| AnalyzerDiagnostic !W.AnalyzeError
| MissingExtImportDiagnostic !SourceSpan !MissingExtImportReason !WaspStyleExtFilePath
deriving (Eq, Show)
data MissingImportReason = NoDefaultExport | NoNamedExport !String | NoFile
data MissingExtImportReason = 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"
showMissingImportReason :: MissingExtImportReason -> WaspStyleExtFilePath -> Text
showMissingImportReason NoDefaultExport (WaspStyleExtFilePath tsFile) =
"No default export in " <> Text.pack tsFile
showMissingImportReason (NoNamedExport name) (WaspStyleExtFilePath tsFile) =
"`" <> Text.pack name <> "` is not exported from " <> Text.pack tsFile
showMissingImportReason NoFile (WaspStyleExtFilePath tsFile) =
"Module " <> Text.pack tsFile <> " does not exist"
missingImportSeverity :: MissingImportReason -> LSP.DiagnosticSeverity
missingImportSeverity :: MissingExtImportReason -> 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) =
waspDiagnosticToLspDiagnostic _ (AnalyzerDiagnostic analyzeError) = waspErrorToDiagnostic analyzeError
waspDiagnosticToLspDiagnostic src (MissingExtImportDiagnostic sourceSpan reason tsFile) =
let message = showMissingImportReason reason tsFile
severity = missingImportSeverity reason
region = sourceSpanToRegion src sourceSpan
@ -116,8 +116,8 @@ waspErrorRange err =
let (_, W.Ctx rgn) = W.getErrorMessageAndCtx err
in waspSourceRegionToLspRange rgn
clearMissingImportDiagnostics :: [WaspDiagnostic] -> [WaspDiagnostic]
clearMissingImportDiagnostics = filter (not . isMissingImportDiagnostic)
clearMissingExtImportDiagnostics :: [WaspDiagnostic] -> [WaspDiagnostic]
clearMissingExtImportDiagnostics = filter (not . isMissingImportDiagnostic)
where
isMissingImportDiagnostic (MissingImportDiagnostic _ _ _) = True
isMissingImportDiagnostic (MissingExtImportDiagnostic _ _ _) = True
isMissingImportDiagnostic _ = False

View File

@ -0,0 +1,116 @@
{-# LANGUAGE DataKinds #-}
module Wasp.LSP.DynamicHandlers
( registerDynamicCapabilities,
)
where
import Control.Lens ((.~), (^.))
import Control.Monad (forM_, (<=<))
import Control.Monad.Log.Class (logM)
import Control.Monad.Reader.Class (asks)
import Data.List (isSuffixOf, stripPrefix)
import Data.Maybe (mapMaybe)
import qualified Data.Text as T
import qualified Data.Text as Text
import qualified Language.LSP.Server as LSP
import qualified Language.LSP.Types as LSP
import qualified Language.LSP.Types.Lens as LSP
import qualified StrongPath as SP
import Wasp.LSP.Analysis (publishDiagnostics)
import Wasp.LSP.ExtImport.Diagnostic (updateMissingExtImportDiagnostics)
import Wasp.LSP.ExtImport.ExportsCache (refreshExportsOfFiles)
import Wasp.LSP.ServerMonads (ServerM, handler, modify, sendToReactor)
import qualified Wasp.LSP.ServerState as State
-- | Sends capability registration requests for all dynamic capabilities that
-- @waspls@ uses.
--
-- Dynamic capability registration comes from the LSP specification:
-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-16/#client_registerCapability
--
-- This is different than static registration, which occurs when the server is
-- initialized: statically registered capabilities can never be unregistered and
-- only some capabilities can be statically registered.
--
-- In contrast, dynamic registration allows us to register handlers for any
-- capability, as well as unregister it. Specific to the haskell @lsp@ library
-- we use, we can also track whether the capability was successfully registered
-- only for dynamic capabilities.
--
-- In turn, clients may only support a limited set of these capabilities, so
-- the LSP allows the client to refuse to register some capabilities that waspls
-- tries to register. See each @registerX@ function for details on what
-- functionality is lost if registration for that capability fails.
registerDynamicCapabilities :: ServerM ()
registerDynamicCapabilities =
sequence_
[ registerSourceFileWatcher
]
-- | Register file watcher watcher for JS and TS files in the src/ directory.
-- When these files change, the export lists for the changed files are
-- automatically refreshed.
--
-- Note that this registration is guaranteed: if it fails, places in @waspls@ that
-- rely on up-to-date export lists need to manually refresh the export lists.
registerSourceFileWatcher :: ServerM ()
registerSourceFileWatcher = do
-- We try to watch just the @src/@ directory, but we can only specify absolute
-- glob patterns. So we can only do this when the root path is available, which
-- it practically always is after @Initialized@ is sent.
--
-- NOTE: relative glob patterns are introduced in the LSP spec 3.17, but are
-- not available in 3.16. We are limited to 3.16 because we use lsp-1.4.0.0.
let tsJsGlobPattern = "**/*.{ts,tsx,js,jsx}"
globPattern <-
LSP.getRootPath >>= \case
Nothing -> do
logM "Could not access projectRootDir when setting up source file watcher. Watching any TS/JS file instead of limiting to src/."
return tsJsGlobPattern
Just projectRootDir -> do
let srcGlobPattern = "src/" <> tsJsGlobPattern
return $
if "/" `isSuffixOf` projectRootDir
then projectRootDir <> srcGlobPattern
else projectRootDir <> "/" <> srcGlobPattern
watchSourceFilesToken <-
LSP.registerCapability
LSP.SWorkspaceDidChangeWatchedFiles
LSP.DidChangeWatchedFilesRegistrationOptions
{ _watchers =
LSP.List
[ LSP.FileSystemWatcher
{ _globPattern = Text.pack globPattern,
_kind = Nothing
}
]
}
sourceFilesChangedHandler
case watchSourceFilesToken of
Nothing -> logM "[initializedHandler] Client did not accept WorkspaceDidChangeWatchedFiles registration"
Just _ -> logM $ "[initializedHandler] WorkspaceDidChangeWatchedFiles registered for JS/TS source files. Glob pattern: " <> globPattern
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.
sourceFilesChangedHandler :: LSP.Handler ServerM 'LSP.WorkspaceDidChangeWatchedFiles
sourceFilesChangedHandler 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
refreshExportsOfFiles [file]
-- Update diagnostics for the wasp file
updateMissingExtImportDiagnostics
handler $
asks (^. State.waspFileUri) >>= \case
Just uri -> do
logM $ "[watchSourceFilesHandler] Updating missing diagnostics for " ++ show uri
publishDiagnostics uri
Nothing -> pure ()

View File

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

View File

@ -0,0 +1,51 @@
module Wasp.LSP.ExtImport.Diagnostic
( updateMissingExtImportDiagnostics,
getMissingExtImportDiagnostics,
)
where
import Control.Lens ((%~), (^.))
import Control.Monad.Reader.Class (asks)
import Data.Maybe (catMaybes)
import Wasp.Analyzer.Parser (ExtImportName (ExtImportField, ExtImportModule))
import qualified Wasp.Analyzer.Parser.CST.Traverse as T
import Wasp.LSP.Diagnostic (MissingExtImportReason (..), WaspDiagnostic (MissingExtImportDiagnostic), clearMissingExtImportDiagnostics)
import Wasp.LSP.ExtImport.ExportsCache (ExtImportLookupResult (..), lookupExtImport)
import Wasp.LSP.ExtImport.Syntax (ExtImportNode (einLocation), getAllExtImports)
import Wasp.LSP.ServerMonads (HandlerM, ServerM, handler, modify)
import qualified Wasp.LSP.ServerState as State
-- | Clears missing external import diagnostics and computes new diagnostics to
-- replace them with. Does not publish the diagnostics.
updateMissingExtImportDiagnostics :: ServerM ()
updateMissingExtImportDiagnostics = do
newDiagnostics <- handler getMissingExtImportDiagnostics
modify (State.latestDiagnostics %~ ((++ newDiagnostics) . clearMissingExtImportDiagnostics))
-- | Finds missing external import diagnostics for all ExtImports in the current
-- concrete syntax tree.
getMissingExtImportDiagnostics :: HandlerM [WaspDiagnostic]
getMissingExtImportDiagnostics =
asks (^. State.cst) >>= \case
Nothing -> return []
Just syntax -> do
src <- asks (^. State.currentWaspSource)
let allExtImports = getAllExtImports src syntax
catMaybes <$> mapM findDiagnosticForExtImport allExtImports
-- | Check for a diagnostic at a single external import.
findDiagnosticForExtImport :: ExtImportNode -> HandlerM (Maybe WaspDiagnostic)
findDiagnosticForExtImport extImport =
lookupExtImport extImport >>= \case
ImportSyntaxError -> return Nothing -- Syntax errors are already reported elsewhere.
ImportCacheMiss -> return Nothing
ImportedFileDoesNotExist file -> return $ Just $ MissingExtImportDiagnostic extImportSpan NoFile file
ImportedSymbolDoesNotExist symbol file -> return $ Just $ diagnosticForExtImport symbol file
ImportsSymbol _ _ -> return Nothing -- Valid import.
where
diagnosticForExtImport (ExtImportModule _) file =
MissingExtImportDiagnostic extImportSpan NoDefaultExport file
diagnosticForExtImport (ExtImportField name) file =
MissingExtImportDiagnostic extImportSpan (NoNamedExport name) file
extImportSpan = T.spanAt $ einLocation extImport

View File

@ -0,0 +1,133 @@
module Wasp.LSP.ExtImport.ExportsCache
( refreshExportsForAllExtImports,
refreshExportsOfFiles,
lookupExtImport,
ExtImportLookupResult (..),
)
where
import Control.Arrow ((&&&))
import Control.Lens ((%~), (^.))
import Control.Monad (void, (<=<))
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Log.Class (logM)
import Control.Monad.Reader.Class (asks)
import qualified Data.HashMap.Strict as M
import Data.List (find)
import Data.Maybe (catMaybes, fromJust, mapMaybe)
import qualified Language.LSP.Server as LSP
import qualified StrongPath as SP
import Wasp.Analyzer.Parser (ExtImportName (ExtImportField, ExtImportModule))
import Wasp.LSP.ExtImport.Path (WaspStyleExtFilePath, absPathToCachePath, cachePathToAbsPath, tryGetTsconfigForAbsPath, waspStylePathToCachePath)
import Wasp.LSP.ExtImport.Syntax (ExtImportNode (einName, einPath), getAllExtImports)
import Wasp.LSP.ServerMonads (HandlerM, ServerM, handler, modify)
import qualified Wasp.LSP.ServerState as State
import qualified Wasp.TypeScript.Inspect.Exports as TS
-- | Based on the files imported in the external imports of the current concrete
-- syntax tree, refreshes the exports of files that are imported.
refreshExportsForAllExtImports :: ServerM ()
refreshExportsForAllExtImports = do
(src, maybeCst) <- handler $ asks ((^. State.currentWaspSource) &&& (^. State.cst))
case maybeCst of
Nothing -> pure ()
Just syntax -> do
let allExtImports = getAllExtImports src syntax
let allCachePaths = mapMaybe (waspStylePathToCachePath <=< einPath) allExtImports
allTsFiles <- catMaybes <$> mapM cachePathToAbsPath allCachePaths
refreshExportsOfFiles allTsFiles
-- | Update the exports list of several files. Clears the previous cache for
-- each of the files listed, even if it fails to get a new list of exports.
refreshExportsOfFiles :: [SP.Path' SP.Abs SP.File'] -> ServerM ()
refreshExportsOfFiles files = do
logM $ "[refreshExportsOfFiles] refreshing export lists for " ++ show files
cachePaths <- catMaybes <$> mapM absPathToCachePath files
LSP.getRootPath >>= \case
Nothing -> pure ()
Just projectRootDirFilePath -> do
let projectRootDir = fromJust $ SP.parseAbsDir projectRootDirFilePath
let exportRequests = mapMaybe (getExportRequestForFile projectRootDir) files
response <- liftIO (TS.getExportsOfTsFiles exportRequests)
-- Clear cache for all the files that were requested to be updated. This
-- takes care of removing deleted files from the cache.
modify (State.tsExports %~ foldr ((.) . M.delete) id cachePaths)
case response of
Left err -> do
logM $ "[refreshExportsForFile] ERROR getting exports: " ++ show err
Right res -> do
updateExportsCache res
where
-- Find the tsconfig for a given file and return an export request including
-- that tsconfig. If a tsconfig can not be found (see 'tryGetTsConfigForAbsPath'
-- for why that might happen), no export request is no returned.
getExportRequestForFile projectRootDir file = do
tsconfigPath <- tryGetTsconfigForAbsPath projectRootDir file
return $
TS.TsExportsRequest
{ TS.filepaths = [SP.fromAbsFile file],
TS.tsconfig = Just $ SP.fromAbsFile tsconfigPath
}
-- Replaces entries in the exports cache with the exports lists in the
-- response.
updateExportsCache :: TS.TsExportsResponse -> ServerM ()
updateExportsCache (TS.TsExportsResponse res) = do
newExports <- M.fromList <$> mapM exportResKeyToCachePath (M.toList res)
void $ modify $ State.tsExports %~ (newExports `M.union`)
exportResKeyToCachePath (key, exports) = case SP.parseAbsFile key of
Nothing -> error "[refreshExportsOfFiles]: TS.getExportsOfTsFiles did not respond with a valid path"
Just path ->
absPathToCachePath path >>= \case
Nothing -> error "[refreshExportsOfFiles]: exports refreshed outside of wasp src/ dir (could not get cache path)"
Just cachePath -> return (cachePath, exports)
-- | 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 !WaspStyleExtFilePath
| -- | Imports a symbol that is not exported from the file it imports.
ImportedSymbolDoesNotExist !ExtImportName !WaspStyleExtFilePath
| -- | Sucessful lookup: includes the file and exported symbol.
ImportsSymbol !(SP.Path' SP.Abs SP.File') TS.TsExport
deriving (Eq, Show)
-- | Find the 'TS.TsExport' for the given external import in the export cache.
lookupExtImport :: ExtImportNode -> HandlerM ExtImportLookupResult
lookupExtImport extImport = case (waspStylePathToCachePath =<< einPath extImport, einPath extImport) of
(Just cachePath, Just waspStylePath) ->
asks ((M.!? cachePath) . (^. State.tsExports)) >>= \case
Nothing -> lookupCacheMiss waspStylePath cachePath
Just exports -> lookupCacheHit cachePath waspStylePath exports
_ -> return ImportSyntaxError -- No import path provided or the provided path is invalid.
where
lookupCacheMiss waspStylePath cachePath =
cachePathToAbsPath cachePath >>= \case
Nothing -> return $ ImportedFileDoesNotExist waspStylePath
Just _ -> return ImportCacheMiss
lookupCacheHit cachePath waspStylePath exports = case einName extImport of
Nothing -> return ImportSyntaxError -- No valid name imported.
Just name -> do
case find (isImportedExport name) exports of
Just export ->
cachePathToAbsPath cachePath >>= \case
Nothing -> error "[lookupExtImport]: file does not exist after verifying the import is valid"
Just tsFile -> return $ ImportsSymbol tsFile export
Nothing -> return $ ImportedSymbolDoesNotExist name waspStylePath
-- A predicate to check if a 'TS.TsExport' matches an imported 'ExtImportName'.
isImportedExport importedName = case importedName of
ExtImportModule _ -> \case
TS.DefaultExport _ -> True
_ -> False
ExtImportField name -> \case
TS.NamedExport n _ | n == name -> True
_ -> False

View File

@ -0,0 +1,159 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Wasp.LSP.ExtImport.Path
( ExtFileCachePath,
WaspStyleExtFilePath (WaspStyleExtFilePath),
waspStylePathToCachePath,
absPathToCachePath,
cachePathToAbsPath,
tryGetTsconfigForAbsPath,
)
where
import Control.Applicative ((<|>))
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.Hashable (Hashable (hashWithSalt))
import Data.List (stripPrefix)
import GHC.Generics (Generic)
import qualified Language.LSP.Server as LSP
import qualified Path as P
import qualified StrongPath as SP
import qualified StrongPath.Path as SP
import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
import Wasp.Project.Common (WaspProjectDir)
import Wasp.Util.IO (doesFileExist)
data ExtensionlessExtFile
-- | Path used in the exports cache for external code files (JS and TS files).
--
-- It is stored relative to @src/@ and without an extension so that cache lookups
-- (starting with a 'WaspStyleExtFilePath') are more efficient.
data ExtFileCachePath
= ExtFileCachePath !(SP.Path' (SP.Rel SourceExternalCodeDir) (SP.File ExtensionlessExtFile)) !ExtensionType
deriving (Show, Eq, Generic)
-- | Hashes only the path portion (ignoring the extension type). This is so that
-- hashing does not get in the way of equality comparisons while looking up
-- 'ExtFileCachePath's in a "Data.HashMap".
instance Hashable ExtFileCachePath where
hashWithSalt salt (ExtFileCachePath cachePath _) = hashWithSalt salt cachePath
-- | A path that would appear in an external import, exactly as it is written in
-- Wasp source code.
--
-- For example, @\"\@server/queries.js\"@ is a wasp style path, but @\"src\/server\/queries.js\"@
-- is not.
newtype WaspStyleExtFilePath = WaspStyleExtFilePath String deriving (Show, Eq)
waspStylePathToCachePath :: WaspStyleExtFilePath -> Maybe ExtFileCachePath
waspStylePathToCachePath (WaspStyleExtFilePath waspStylePath) = do
-- Removes the @, making it a path relative to src/, and remove the extension.
relFile <- P.parseRelFile =<< stripPrefix "@" waspStylePath
let (extensionLessFile, extType) = splitExtensionType relFile
return $ ExtFileCachePath (SP.fromPathRelFile extensionLessFile) extType
absPathToCachePath :: LSP.MonadLsp c m => SP.Path' SP.Abs SP.File' -> m (Maybe ExtFileCachePath)
absPathToCachePath absFile = do
-- Makes the path relative to src/ and deletes the extension.
maybeProjectDir <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
case maybeProjectDir of
Nothing -> pure Nothing
Just (projectRootDir :: SP.Path' SP.Abs (SP.Dir WaspProjectDir)) ->
let srcDir = projectRootDir SP.</> srcDirInProjectRootDir
in case P.stripProperPrefix (SP.toPathAbsDir srcDir) (SP.toPathAbsFile absFile) of
Nothing -> pure Nothing
Just relFile -> do
let (extensionLessFile, extType) = splitExtensionType relFile
pure $ Just $ ExtFileCachePath (SP.fromPathRelFile extensionLessFile) extType
cachePathToAbsPath :: forall m c. LSP.MonadLsp c m => ExtFileCachePath -> m (Maybe (SP.Path' SP.Abs SP.File'))
cachePathToAbsPath (ExtFileCachePath cachePath extType) = do
-- Converts to an absolute path and finds the appropriate extension.
maybeProjectDir <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
case maybeProjectDir of
Nothing -> pure Nothing
Just (projectRootDir :: SP.Path' SP.Abs (SP.Dir WaspProjectDir)) -> do
let fileWithNoExtension = projectRootDir SP.</> srcDirInProjectRootDir SP.</> cachePath
useFirstExtensionThatExists fileWithNoExtension $ allowedExts extType
where
useFirstExtensionThatExists :: SP.Path' SP.Abs (SP.File ExtensionlessExtFile) -> [String] -> m (Maybe (SP.Path' SP.Abs SP.File'))
useFirstExtensionThatExists _ [] = pure Nothing
useFirstExtensionThatExists file (ext : exts) =
case P.addExtension ext (SP.toPathAbsFile file) of
Nothing -> error "Invalid extension in return from 'allowedExts'"
Just fileWithExt -> do
fileWithExtExists <- liftIO $ doesFileExist $ SP.fromPathAbsFile fileWithExt
if fileWithExtExists
then pure $ Just $ SP.fromPathAbsFile fileWithExt
else useFirstExtensionThatExists file exts
-- | Try to find the @tsconfig.json@ file based on the location of the given
-- file.
--
-- Returns either @src/client/tsconfig.json@ or @src/server/tsconfig.json@,
-- depending on which directory the file is in. Does not check if those
-- config files exist.
--
-- IF the given path is not in either @src/@ subdirectory, returns nothing.
tryGetTsconfigForAbsPath :: SP.Path' SP.Abs (SP.Dir WaspProjectDir) -> SP.Path' SP.Abs SP.File' -> Maybe (SP.Path' SP.Abs SP.File')
tryGetTsconfigForAbsPath projectRootDir 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 = projectRootDir SP.</> folder
in if SP.toPathAbsDir absFolder `P.isProperPrefixOf` SP.toPathAbsFile file
then Just $ absFolder SP.</> [SP.relfile|tsconfig.json|]
else Nothing
srcDirInProjectRootDir :: SP.Path' (SP.Rel WaspProjectDir) (SP.Dir SourceExternalCodeDir)
srcDirInProjectRootDir = [SP.reldir|src|]
-- | The \"type\" of an extension. This type is related to how TypeScript resolves
-- module extensions.
data ExtensionType
= -- | @.ts@ or @.js@.
DotTJS
| -- | @.tsx@ or @.jsx@.
DotTJSX
| -- | @.ts@, @.js@, @.tsx@, or @.jsx@.
DotAnyTS
| -- | @DotExact ext@ is exactly the extension @ext@.
DotExact !String
deriving (Show)
-- | Convert an extension (like @\".js\"@) to an 'ExtensionType'.
extensionType :: String -> ExtensionType
extensionType ".js" = DotTJS
extensionType ".jsx" = DotTJSX
extensionType ext = DotExact ext
-- | Split a path into a path with no extension and an extension type. If the
-- file has no extension, it gives it 'DotAnyTS' extension type.
splitExtensionType :: P.Path b P.File -> (P.Path b P.File, ExtensionType)
splitExtensionType file = case P.splitExtension file of
Nothing -> (file, DotAnyTS)
Just (file', ext) -> (file', extensionType ext)
-- | The extensions that each 'ExtensionType' represents.
allowedExts :: ExtensionType -> [String]
allowedExts DotTJS = [".ts", ".js"]
allowedExts DotTJSX = [".tsx", ".jsx"]
allowedExts DotAnyTS = [".ts", ".js", ".tsx", ".jsx"]
allowedExts (DotExact ext) = [ext]
-- | The 'Eq' instance on this type is slightly weird: it represents compatibilty,
-- not equality. Specifically, two extension types are equal if they share at
-- least one common extension. This instance is written this way so that the
-- "Data.Hashmap" using this as a key (the exports cache) behaves properly when
-- extension types are different between the key in the cache and the key in the
-- lookup request.
instance Eq ExtensionType where
DotTJS == DotTJS = True
DotTJSX == DotTJSX = True
DotAnyTS == DotTJS = True
DotAnyTS == DotTJSX = True
DotAnyTS == DotAnyTS = True
DotExact ext == right = ext `elem` allowedExts right
left == right = right == left

View File

@ -0,0 +1,73 @@
module Wasp.LSP.ExtImport.Syntax
( ExtImportNode (..),
findExtImportAroundLocation,
getAllExtImports,
)
where
import Control.Applicative ((<|>))
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.ExtImport.Path (WaspStyleExtFilePath (WaspStyleExtFilePath))
import Wasp.LSP.Syntax (findChild, lexemeAt)
-- | ExtImport syntax node.
data ExtImportNode = ExtImportNode
{ -- | Location of the 'S.ExtImport' node
einLocation :: !Traversal,
einName :: !(Maybe ExtImportName),
-- | Import path, exactly as it appears in the Wasp source code.
einPath :: !(Maybe WaspStyleExtFilePath)
}
-- | Create a 'ExtImportNode' at a location, assuming the location is at a
-- 'S.ExtImport'. It is up to the callee to ensure this.
extImportAtLocation :: String -> Traversal -> ExtImportNode
extImportAtLocation src location =
let maybeName =
(ExtImportModule . lexemeAt src <$> findChild S.ExtImportModule location)
<|> (ExtImportField . lexemeAt src <$> findChild S.ExtImportField location)
maybePathLexeme = lexemeAt src <$> findChild S.ExtImportPath location
-- Parse the string lexeme into a Haskell string
maybeWaspStylePath = WaspStyleExtFilePath <$> (readMaybe =<< maybePathLexeme)
in ExtImportNode
{ einLocation = location,
einName = maybeName,
einPath = maybeWaspStylePath
}
-- | Look for an 'S.ExtImport' node that is either at the given location or is
-- an ancestor of the location.
--
-- For example, passing in a location pointing to an 'S.ExtImportPath' will
-- return an 'ExtImportNode' for the parent node of that path node.
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
-- | Finds all external imports in a concrete syntax tree.
getAllExtImports ::
-- | Wasp source code.
String ->
-- | Syntax forest of the entire Wasp file.
[S.SyntaxNode] ->
[ExtImportNode]
getAllExtImports src syntax = go $ T.fromSyntaxForest syntax
where
go :: Traversal -> [ExtImportNode]
go t = case T.kindAt t of
S.ExtImport -> [extImportAtLocation src t]
_ -> concatMap go $ T.children t

View File

@ -12,12 +12,13 @@ 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.ExtImport.ExportsCache as ExtImport
import qualified Wasp.LSP.ExtImport.Syntax as ExtImport
import Wasp.LSP.ServerMonads (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
import qualified Wasp.TypeScript.Inspect.Exports as TS
definitionProviders :: [String -> Traversal -> HandlerM [LSP.LocationLink]]
definitionProviders = [extImportDefinitionProvider]

View File

@ -12,40 +12,22 @@ module Wasp.LSP.Handlers
)
where
import Control.Lens ((.~), (?~), (^.))
import Control.Monad (forM_, when, (<=<))
import Control.Lens ((^.))
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 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.Analysis (diagnoseWaspFile)
import Wasp.LSP.Completion (getCompletionsAtPosition)
import Wasp.LSP.Debouncer (debounce)
import Wasp.LSP.Diagnostic (WaspDiagnostic (AnalyzerDiagonstic, ParseDiagnostic), waspDiagnosticToLspDiagnostic)
import Wasp.LSP.ExtImport (refreshAllExports, refreshExportsForFiles, updateMissingImportDiagnostics)
import Wasp.LSP.DynamicHandlers (registerDynamicCapabilities)
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.ServerMonads (ServerM, handler)
import Wasp.LSP.SignatureHelp (getSignatureHelpAtPosition)
-- LSP notification and request handlers
-- | "Initialized" notification is sent when the client is started. We don't
-- have anything we need to do at initialization, but this is required to be
-- implemented.
-- | "Initialized" notification is sent when the client is started. We send
-- all of our dynamic capability registration requests when this happens.
--
-- The client starts the LSP at its own discretion, but commonly this is done
-- either when:
@ -57,47 +39,7 @@ import Wasp.LSP.SignatureHelp (getSignatureHelpAtPosition)
initializedHandler :: Handlers ServerM
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 ()
registerDynamicCapabilities
-- | 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:
@ -148,83 +90,6 @@ signatureHelpHandler =
signatureHelp <- handler $ getSignatureHelpAtPosition position
respond $ Right signatureHelp
-- | Does not directly handle a notification or event, but should be run when
-- text document content changes.
--
-- It analyzes the document contents and sends any error messages back to the
-- LSP client. In the future, it will also store information about the analyzed
-- file in "Wasp.LSP.State.State".
diagnoseWaspFile :: LSP.Uri -> ServerM ()
diagnoseWaspFile uri = do
analyzeWaspFile uri
-- 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
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
readSourceString = fmap T.unpack <$> readVFSFile uri
storeCSTErrors concreteErrorMessages = do
let newDiagnostics = map ParseDiagnostic concreteErrorMessages
modify (latestDiagnostics .~ newDiagnostics)
runWaspAnalyzer srcString = do
let analyzeResult = analyze srcString
case analyzeResult of
Right _ -> do
modify (latestDiagnostics .~ [])
Left errs -> do
let newDiagnostics = map AnalyzerDiagonstic errs
modify (latestDiagnostics .~ newDiagnostics)
-- | Read the contents of a "Uri" in the virtual file system maintained by the
-- LSP library.
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".
-- | 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
extractUri = (^. (LSP.params . LSP.textDocument . LSP.uri))

View File

@ -5,6 +5,16 @@ module Wasp.LSP.Reactor
-- 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.
--
-- Essentially, this is a thread pool with only 1 thread. The primary reason
-- for this is to make reasoning about concurrent modifications easier, since
-- only the reactor thread and the main thread are running.
--
-- Just 1 thread for these long running tasks should be fine: in general,
-- these tasks are triggered by actions from the user who is editing a wasp
-- project, which is relatively slow compared to how fast these tasks can
-- finish. For tasks that are being triggered often, consider debouncing
-- the source to reduce how often it is triggered ("Wasp.LSP.Debouncer").
ReactorInput (..),
reactor,
startReactorThread,

View File

@ -23,7 +23,7 @@ import Wasp.LSP.Debouncer (newDebouncerIO)
import Wasp.LSP.Handlers
import Wasp.LSP.Reactor (startReactorThread)
import Wasp.LSP.ServerConfig (ServerConfig)
import Wasp.LSP.ServerM (ServerM, runRLspM)
import Wasp.LSP.ServerMonads (ServerM, runRLspM)
import Wasp.LSP.ServerState
( RegistrationTokens (RegTokens, _watchSourceFilesToken),
ServerState (ServerState, _cst, _currentWaspSource, _debouncer, _latestDiagnostics, _reactorIn, _regTokens, _tsExports, _waspFileUri),

View File

@ -1,6 +1,6 @@
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Wasp.LSP.ServerM
module Wasp.LSP.ServerMonads
( -- * LSP Server Monads
-- The state of the LSP server is used in two different ways:

View File

@ -26,12 +26,12 @@ 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.ExtImport.Path (ExtFileCachePath)
import Wasp.LSP.Reactor (ReactorInput)
import Wasp.TypeScript (TsExport)
import Wasp.TypeScript.Inspect.Exports (TsExport)
-- | LSP State preserved between handlers.
--
@ -59,7 +59,7 @@ data ServerState = ServerState
}
-- | 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]
type TsExportCache = M.HashMap ExtFileCachePath [TsExport]
-- | LSP dynamic capability registration tokens.
--

View File

@ -62,7 +62,7 @@ The party starts at **9.30 am EDT / 3.30 pm CET** - sign up [here](https://disco
As per usual, there will be memes, swag and lots of interesting dev discussions!
## Monday: The future is now 🛸
## Auto CRUD | Monday: The future is now 🛸
<ImgWithCaption
alt="The future is now"
@ -75,9 +75,11 @@ That's what we are coming after - is it possible to avoid writing (or generating
**When**: Monday, June 26 2023
**Read more about it**: coming soon
**Read more about it**:
- [Twitter thread introing Auto CRUD](https://twitter.com/WaspLang/status/1673376102792806402)
- [Docs Guide to Auto CRUD](/docs/guides/crud)
## Tuesday: Be real, time 🔌⏱
## WebSocket Support | Tuesday: Be real, time 🔌⏱
<ImgWithCaption
alt="Realtime"
@ -90,7 +92,9 @@ Another situation where you might want to keep things real is when chatting to s
**When**: Tuesday, June 27 2023
**Read more about it**: coming soon
**Read more about it**:
- [Intro Twitter thread](https://twitter.com/WaspLang/status/1673742264873500673)
- [Docs Guide](/docs/guides/websockets)
## Wednesday: Community Day 🤗
@ -104,9 +108,9 @@ Community is at the centre of Wasp, and Wednesday is at the centre of the week,
**When**: Wednesday, June 28 2023
**Read more about it**: coming soon
**Read more about it**: [What can you build with Wasp?](/blog/2023/06/28/what-can-you-build-with-wasp)
## Thursday: Take care of your tools 🛠
## Wasp LSP 2.0 | Thursday: Take care of your tools 🛠
<ImgWithCaption
alt="Tools"
@ -119,9 +123,9 @@ Us at Wasp, we are pretty much the same as Gimli - we take our tools seriously.
**When**: Thursday, June 29 2023
**Read more about it**: coming soon
**Read more about it**: [A blog post introing Wasp LSP 2.0](https://wasp-lang.dev/blog/2023/06/29/new-wasp-lsp)
## Friday: Waspularity 🤖 + Tutorial-o-thon!
## GPT Web App Generator | Friday: Waspularity 🤖 + Tutorial-o-thon!
<ImgWithCaption
alt="Waspularity"
@ -134,7 +138,10 @@ To wrap the week up, we'll also start another hackathon, but this time in a bit
**When**: Friday, June 30 2023
**Read more about it**: coming soon
**Read more about it**:
- [Intro Twitter thread](https://twitter.com/WaspLang/status/1674814873312608257)
- [Try GPT Web App Generator!](https://magic-app-generator.wasp-lang.dev/)
- [Join our Tutorial Jam #1 and win prizes!](http://localhost:3002/blog/2023/06/30/tutorial-jam)
## Recap

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,107 @@
---
title: 'What can you build with Wasp?'
authors: [matijasos]
image: /img/build-with-wasp/build-with-wasp-banner.png
tags: [launch-week, showcase]
---
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import InBlogCta from './components/InBlogCta';
import WaspIntro from './_wasp-intro.md';
import ImgWithCaption from './components/ImgWithCaption'
<ImgWithCaption
alt="Launch Week 3 is coming"
source="img/build-with-wasp/build-with-wasp-banner.png"
/>
Welcome to the 3rd day of our [Launch Week #3](blog/2023/06/22/wasp-launch-week-three) - Community Day! Our community is the most important aspect of everything we do at Wasp, and we believe it's only right to have a day dedicated to it.
We'll showcase some of the coolest project built with Wasp so far and through that explore together what kind of apps you can develop with it. Let's dive in!
:::tip
If you're looking for a quick way to start your project, check out our [Ultimate SaaS Starter](https://github.com/wasp-lang/SaaS-Template-GPT). It packs Tailwind, GPT, Stripe ane other popular integrations, all pre-configured for you.
:::
## [CoverLetterGPT.xyz](https://coverlettergpt.xyz/) - GPT-powered cover letter generator
<ImgWithCaption
source="img/build-with-wasp/cover-letter-gpt.png"
/>
**Try it out**: [coverlettergpt.xyz](https://coverlettergpt.xyz/)
**Source code**: https://github.com/vincanger/coverlettergpt
**Wasp features used**: [Social login with Google + auth UI](/blog/2023/04/12/auth-ui), [email sending](http://localhost:3002/docs/guides/sending-emails)
**UI Framework**: [Chakra UI](https://chakra-ui.com/)
Created in the midst of a GPT craze, this is one of the most popular Wasp apps so far! It does exactly what it says on a tin - given job description and your CV, it generates a unique cover letter customized for you. It does that via parsing your CV and feeding it together with the job description to the GPT api, along with the additional settings such as creativity level (careful with that one!).
Although it started as a fun side project, it seems that people actually find it useful, at least as a starting point for writing your own cover letter. CoverLetterGPT has been used to generate close to 5,000 cover letters!
Try it out and have fun or use it as an inspiration for your next project!
## [Amicus.work](https://www.amicus.work/) - most "enterprise SaaS" app 👔 💼
<ImgWithCaption
source="img/build-with-wasp/amicus.png"
/>
**Try it out**: [amicus.work](https://www.amicus.work/)
**Wasp features used**: [Authentication](https://wasp-lang.dev/docs/language/features#authentication--authorization), [email sending](http://localhost:3002/docs/guides/sending-emails), [async/cron jobs](https://wasp-lang.dev/docs/language/features#jobs)
**UI Framework**: [Material UI](https://mui.com/)
This app really gives away those "enterprise SaaS" vibes - when you see it you know it means some serious business! The author describes it as "Asana for you lawyers" ([you can read how the author got first customers for it here](blog/2023/02/14/amicus-indiehacker-interview)), or as an easy way for lawyers to manage and collaborate on their workflows.
File upload, workflow creation, calendar integration, collaboration - this app has it all! Amicus might be the most advanced project made with Wasp so far. Erlis startedbuilding it even with Wasp still in Alpha, and it has withstood the test of time since then.
## Description Generator - GPT-powered product description generator - first acquired app made with Wasp! 💰💰
<ImgWithCaption
source="img/build-with-wasp/description-generator.png"
/>
**Try it out**: [description-generator.online](https://description-generator.online/)
**Wasp features used**: [Social login with Google + auth UI](/blog/2023/04/12/auth-ui)
**UI Framework**: [Chakra UI](https://chakra-ui.com/)
Another SaaS that uses GPT integration to cast its magic! Given product name and instructions on what kind of content you'd like to get, this app generates the professionaly written product listing. It's a perfect fit for marketplace owners that want to present their products in the best light but don't have a budget for the marketing agency.
What's special about Description Generator is that it was recently sold , making it the first Wasp-powered project that got acquired! Stay tuned, as the whole story is coming soon.
## TweetBot - your personal Twitter intern! 🐦🤖
<ImgWithCaption
source="img/build-with-wasp/tweet-bot.png"
/>
**Try it out**: [banger-tweet-bot.netlify.app](https://banger-tweet-bot.netlify.app/)
**Source code**: https://github.com/vincanger/banger-tweet-bot
**Wasp features used**:[Authentication](https://wasp-lang.dev/docs/language/features#authentication--authorization), [async/cron jobs](https://wasp-lang.dev/docs/language/features#jobs)
**UI Framework**: [Tailwind](https://tailwindcss.com/)
The latest and greatest from [Vince's](https://twitter.com/hot_town) lab - an app that serves as your personal twitter brainstorming agent! It takes your raw ideas as an input, monitors current twitter trends (from the accounts you selected) and helps you brainstorm new tweets and also drafts them for you!
While the previously mentioned projects queried the GPT API directly, TweetBot makes use of the [LangChain](https://js.langchain.com/) library, which does a lot of heavy lifting for you, allowing you to produce bigger prompts and preserve the context between subsequent queries.
## Summary
As you could see above, Wasp can be used to build pretty much any database-backed web application! It is especially well suited for so called "workflow-based" applications where you typically have a bunch of resources (e.g. your tasks, or tweets) that you want to manipulate in some way.
With our built-in deployment support (e.g. you can [deploy to Fly.io for free with a single CLI command](https://wasp-lang.dev/docs/deploying)) the whole development process is extremely streamlined.
We can't wait to see what you build next!

View File

@ -0,0 +1,65 @@
---
title: 'Wasp LSP 2.0 - Next-level autocompletion and IDE integration for Wasp projects!'
authors: [matijasos]
image: /img/new-lsp/new-lsp-banner.png
tags: [launch-week, product-update]
---
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import InBlogCta from './components/InBlogCta';
import WaspIntro from './_wasp-intro.md';
import ImgWithCaption from './components/ImgWithCaption'
<ImgWithCaption
source="img/new-lsp/new-lsp-banner.png"
/>
It's the fourth day of our [Launch Week #3](blog/2023/06/22/wasp-launch-week-three) - today it's all about dev tooling and making sure that the time you spend looking at your IDE is as pleasurable as possible!
**We present the next generation of Wasp LSP (Language Server Protocol) implementation for [VS Code](https://marketplace.visualstudio.com/items?itemName=wasp-lang.wasp)**! As you might already know, Wasp has its own simple configuration language (`.wasp`) that acts as a glue between your React & Node.js code.
Although it's a very simple, declarative language (you can think of it as a bit nicer/smarter JSON), and having it allows us to completely tailor the developer experience (aka get rid of boilerplate), it also means we have to provide our own tooling for it (syntax highlighting, auto completion, ...).
We started with syntax highlighting, then basic autocompletion and snippet support, but now we really took things to the next level! Writing Wasp code now is much closer to what we had in our mind when envisioning Wasp.
Without further ado, here's what's new:
## ✨ Autocompletion for config object properties (`auth`, `webSocket`, ...)
Until now, Wasp offered autocompletion only for the top-level declarations such as `page` or `app`. Now, it works for any (sub)-property (as one would expect 😅)!
<ImgWithCaption
source="img/new-lsp/dict-completion.gif"
caption="Fill out your Wasp configuration faster and with less typos! 💻🚀"
/>
## 🔍 Type Hints
Opening documentation takes you out of your editor and out of your flow. Stay in the zone with in-editor type hints! 💡
<ImgWithCaption
source="img/new-lsp/type-hints.gif"
/>
## 🚨 Import Diagnostics
Keep tabs on what's left to implement with JS import diagnostics! There's nothing more satisfying than watching those errors vanish. 😌
<ImgWithCaption
source="img/new-lsp/import-diagnostics.gif"
caption="Wasp now automatically detects if the function you referenced doesn't exist or is not exported."
/>
## 🔗 Goto Definition
Your Wasp file is the central hub of your project. Easily navigate your code with goto definition and make changes in a snap! 💨
<ImgWithCaption
source="img/new-lsp/goto-definition.gif"
caption="Cmd/Ctrl + click and Wasp LSP takes you straight to the function body!"
/>
Don't forget to install [Wasp VS Code extension](https://marketplace.visualstudio.com/items?itemName=wasp-lang.wasp) and we wish you happy coding! You can get started right away and [try it out here](/docs/quick-start).

View File

@ -0,0 +1,114 @@
---
title: 'Tutorial Jam #1 - Teach Others & Win Prizes!'
authors: [vinny]
image: /img/tutorial-jam/tutorial-jam-banner.png
tags: [launch-week, product-update]
---
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import InBlogCta from './components/InBlogCta';
import WaspIntro from './_wasp-intro.md';
import ImgWithCaption from './components/ImgWithCaption'
<ImgWithCaption
source="img/tutorial-jam/tutorial-jam-banner.png"
/>
## Introduction
The Wasp Tutorial Jam is a contest where participants are required to create a tutorial about building a fullstack React/Node app with Wasp.
## Wait, Whats Wasp?
First of all, its sad that youve never heard of [Wasp](https://wasp-lang.dev).
![https://media0.giphy.com/media/kr5PszPQawIRq/giphy.gif?cid=7941fdc6gwgjf866b0akslgciedh53jf9narttadkglvvcp0&ep=v1_gifs_search&rid=giphy.gif&ct=g](https://media0.giphy.com/media/kr5PszPQawIRq/giphy.gif?cid=7941fdc6gwgjf866b0akslgciedh53jf9narttadkglvvcp0&ep=v1_gifs_search&rid=giphy.gif&ct=g)
Wasp is a unique fullstack framework for building React/NodeJS/Prisma/Tanstack Query apps.
Because its based on a compiler, you write a simple config file, and Wasp can take care of generating the skeleton of your app for you (and regenerating when the config file changes). You can read more about [Wasp here](https://wasp-lang.dev)
## Rules
The rules are simple. The tutorial must:
- Use Wasp.
- Be written in English.
- Be original content and not copied from any existing sources.
- Be a written tutorial posted to a social blogging platform like [dev.to](http://dev.to) or [hashnode.dev](http://hashnode.dev), or a YouTube video tutorial
- Contain the hashtag `#buildwithwasp`
- Submitted by pasting the link in the #tutorialjam channel on our [Discord Server](https://discord.gg/rzdnErX)
AND
- The tutorial can focus on any topic and be any length (short or long) just as long as it uses Wasps fullstack capabilities.
![https://media1.giphy.com/media/iB4PoTVka0Xnul7UaC/giphy.gif?cid=7941fdc67jeepog7whrdmkbux0c6kxzb8eyhqwpjcd1tunvp&ep=v1_gifs_search&rid=giphy.gif&ct=g](https://media1.giphy.com/media/iB4PoTVka0Xnul7UaC/giphy.gif?cid=7941fdc67jeepog7whrdmkbux0c6kxzb8eyhqwpjcd1tunvp&ep=v1_gifs_search&rid=giphy.gif&ct=g)
## Judging Criteria
The judging criteria for the Tutorial Jam will be based on:
- Clarity and conciseness of the tutorial.
- Creativity and originality of the tutorial.
- Effectiveness of the tutorial in helping the reader understand and use Wasp to create a fullstack web app or demonstrate a web development topic.
## Templates & Tutorial Examples
We have a whole repo of starter templates that you can use with Wasp by [installing wasp](https://wasp-lang.dev/docs/quick-start) and running `wasp new` in the command line. The interactive prompt will ask you what template youd like to start with:
```bash
[1] basic (default)
Simple starter template with a single page.
[2] todo-ts
Simple but well-rounded Wasp app implemented with Typescript & full-stack type safety.
[3] saas
Everything a SaaS needs! Comes with Google auth, ChatGPT API, Tailwind, & Stripe payments.
[4] embeddings
Comes with code for generating vector embeddings and performing vector similarity search.
[5] WaspAI
An AI powered code scaffolder. Tell it what kind of app you want and get a scaffolded fullstack app
```
In addition, here are some ideas to help you get inspired. You could build a simple fullstack app with Wasp in order to explain some key concepts:
- **Wasps New AI-Generated App Feature:** build any fullstack app using Wasps new AI-generated App feature and explain the process.
- *What worked? What didnt? What are some prompt engineering tips? What did you have to do to get the app in a desired final state?*
- **Full-Stack Type Safety:** Using Wasps low-on-boilerplate fullstack typesaftey, you could dive deep into types on both frontend and backend.
- *How does Wasps fullstack typesafety compare to tRPC and/or the T3 stack?*
- **Data Management:** With complete control and easy implementation of data models, you could explore the concepts of databases, data management and relational data in a simplified environment.
- *What are some tips and tricks for working with Prisma and relational DBs?*
- **Understanding Fullstack Web Development:** Wasp being a fullstack tool truly shines a light on how front-end and back-end connect in web development. Its a great tool for understanding how queries, actions, and other operations in back-end can be utilized in front-end components.
- *How does the HTTP protocol work in detail?*
Or you could write a tutorial that explains how to build:
- **A vector-powered AI app:** Leverage Wasps truly fullstack, serverful architecture to build a personalised tool powered by embeddings and vector stores.
- **Realtime Chat or Polling App:** Any realtime app could take advantage of Wasps easy-to-use websockets features. The tutorial could explain handling real-time data, and other basic back-end concepts.
- **Online Shop:** An e-commerce platform model with features like user registration, product display, a shopping cart, and a check-out process, using Wasps easy to configure authorization, and database management.
## Prizes
The winners of the Wasp Tutorial Jam will receive the following prizes:
- First Place: [Wasp-colored mechanical keyboard](https://www.caseking.de/ducky-one-3-yellow-mini-gaming-tastatur-rgb-led-mx-red-us-gata-1745.html?sPartner=999&gclid=Cj0KCQjw1_SkBhDwARIsANbGpFtYpC2-jFuJ94A6VF6oDFLEZQUya3Ky7P9Ih-nU_Zb9NsDjNhmITbIaAtBMEALw_wcB), and your tutorial and info featured on all our blogs (Wasp official website, dev.to, and hashnode)
- Second Place: 3 months access to [PluralSight](https://www.pluralsight.com/) courses (tons of Software Development courses, tutorials and lessons!) or a $75 Amazon giftcard, and your tutorial featured on all our blogs
- Third Place: Wasp Swag and a feature of your tutorial and info on our social media channels.
<ImgWithCaption
source="img/tutorial-jam/keyboard.png"
/>
## Submission Deadline
All submissions must be received by Sunday, July 16th 11:59 p.m. CET.
Winners will be announced the following week.
## Questions?
Head on over to our [Discord Server](https://discord.gg/rzdnErX) and ask away :)
Good luck!

View File

@ -0,0 +1,108 @@
---
title: 'GPT Web App Generator - Let AI create a full-stack React & Node.js codebase based on your description 🤖🤯'
authors: [martinsos]
image: /img/gpt-wasp/gpt-wasp-thumbnail.png
tags: [wasp-ai, GPT]
---
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import ImgWithCaption from './components/ImgWithCaption'
<ImgWithCaption
source="img/gpt-wasp/thumbnail-yellow.png"
/>
This project started out as an experiment - we were interested if, given a short description, GPT can generate a full-stack web app in React & Node.js. The results went beyond our expectations!
## How it works
All you have to do in order to use [GPT Web App Generator](https://magic-app-generator.wasp-lang.dev/) is **provide a short description of your app idea in plain English**. You can optionally select your app's brand color and the preferred authentication method (more methods coming soon).
<ImgWithCaption
source="img/gpt-wasp/how-it-works.gif"
caption="1. Describe your app 2. Pick the color 3. Generate your app 🚀"
/>
That's it - in a matter of minutes, a full-stack web app codebase, written in React, Node.js, Prisma, and Wasp, will be generated right in front of you, and available for you to download, run it locally and deploy with a single CLI command!
See a full one-minute demo here:
<div className='flex justify-center'>
<iframe width="700" height="400" src="https://www.youtube.com/embed/u0MVsPb2MP8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>
## The stack 📚
Besides React & Node.js, GPT Web App Generator uses [Prisma](https://www.prisma.io/) and [Wasp](https://github.com/wasp-lang/wasp).
[Prisma](https://www.prisma.io/) is a type-safe database ORM built on top of PostgreSQL. It makes it easy to deal with data models and database migrations.
[Wasp](https://github.com/wasp-lang/wasp) is a batteries-included, full-stack framework for React & Node.js. It takes care of everything from front-end to back-end and database along with authentication, sending emails, async jobs, deployment, and more.
Additionaly, all the code behind GPT Web App Generator is completely open-source: [web app](https://github.com/wasp-lang/wasp/tree/wasp-ai/wasp-ai), [GPT code agent](https://github.com/wasp-lang/wasp/tree/wasp-ai/waspc/src/Wasp/AI).
## What kind of apps can I build with it?
:::caution
Since this is a GPT-powered project, it's output is not 100% deterministic and small mistakes will sometimes occur in the generated code. For the typical examples of web apps (as seen below) they are usually very minor and straightforward to fix.
If you get stuck, [ping us on our Discord](https://discord.gg/rzdnErX).
:::
The generated apps are full-stack and consist of front-end, back-end and database. Here are few of the examples we successfully created:
### My Plants - track your plants' watering schedule 🌱🚰
<ImgWithCaption
source="img/gpt-wasp/my-plants.png"
/>
- See the generated code and run it yourself [here](https://magic-app-generator.wasp-lang.dev/result/3bb5dca2-f134-4f96-89d6-0812deab6e0c)
This app does exactly what it says - makes sure that you water your plants on time! It comes with a fully functioning front-end, back-end and the database with `User` and `Plant` entities. It also features a [full-stack authentication](/blog/2023/04/12/auth-ui) (username & password) and a Tailwind-based design.
The next step would be to add more advanced features, such as email reminders (via [Wasp email sending support](/docs/guides/sending-emails)) when it is time to water your plant.
You can see and download the [entire source code](https://magic-app-generator.wasp-lang.dev/result/3bb5dca2-f134-4f96-89d6-0812deab6e0c) and add more features and deploy the app yourself!
### ToDo app - a classic ✅
<ImgWithCaption
source="img/gpt-wasp/todo-app.png"
/>
- See the generated code and run it yourself [here](https://magic-app-generator.wasp-lang.dev/result/07ed440a-3155-4969-b3f5-2031fb1f622f)
What kind of a demo would this be if it didn't include a ToDo app? GPT Web App Generator successfully scaffolded it, along with all the basic functionality - creating and marking a task as done.
With the foundations in place (full-stack code, authentication, Tailwind CSS design) you can [see & download the code here](https://magic-app-generator.wasp-lang.dev/result/07ed440a-3155-4969-b3f5-2031fb1f622f) and try it yourself!
### Limitations
In order to reduce the complexity and therefore mistakes GPT makes, for this first version of Generator we went with the following limitations regarding generated apps:
1. No additional npm dependencies.
2. No additional files beyond Wasp Pages (React) and Operations (Node). So no additional files with React components, CSS, utility JS, images or similar.
3. No TypeScript, just Javascript.
4. No advanced Wasp features (e.g. Jobs, Auto CRUD, Websockets, Social Auth, email sending, …).
## Summary & next steps
As mentioned above, our goal was to test whether GPT can be effectively used to generate full-stack web applications with React & Node.js. While it's now obvious it can, we have lot of ideas for new features and improvements.
### Challenges
While we were expecting the main issue to be the size of context that GPT has, it turned out to be that the bigger issue is its “smarts”, which determine things like its planning capabilities, capacity to follow provided instructions (we had quite some laughs observing how it sometimes ignores our instructions), and capacity to not do silly mistakes. We saw GPT4 give better results than GPT3.5, but both still make mistakes, and GPT4 is also quite slow/expensive. Therefore we are quite excited about the further developments in the field of AI / LLMs, as they will directly affect the quality of the output for the tools like our Generator.
### Next features wishlist
1. Get feedback on this initial experiment - both on the Generator and the Wasp as a framework itself: best place to leave us feedback is on our [Discord](https://discord.com/invite/rzdnErX).
2. Further improve code agent & web app.
3. Release new version of `wasp` CLI that allows generating new Wasp project by providing short description via CLI. Our code agent will then use GPT to generate project on the disk. This is already ready and should be coming out soon.
4. Also allow Wasp users to use code agent for scaffolding specific parts of their Wasp app → you want to add a new Wasp Page (React)? Run our code agent via Wasp CLI or via Wasp vscode extension and have it generated for you, with initial logic already implemented.
5. As LLMs progress, try some alternative approaches, e.g. try fine-tuning an LLM with knowledge about Wasp, or give LLM more freedom while generating files and parts of the codebase.
6. Write a detailed blog post about how we implemented the Generator, which techniques we used, how we designed our prompts, what worked and what didnt work, … .
## Support us! ⭐️
If you wish to express your support for what we are doing, consider giving us a [star on Github](https://github.com/wasp-lang/wasp)! Everything we do at Wasp is open source, and your support motivates us and helps us to keep making web app development easier and with less boilerplate.

View File

@ -10,6 +10,12 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
To enable support for Tailwind in your Wasp project, you simply need to add two config files (`tailwind.config.cjs` and `postcss.config.cjs`) to the root directory. When they are present, Wasp will add the necessary NPM dependencies and copy your config files into the generated project output. You can then start adding [Tailwind CSS directives](https://tailwindcss.com/docs/functions-and-directives#directives) to your CSS files and `className`s to your React components.
:::tip
**After adding the required config files, make sure to restart the local Wasp server** via your CLI (just run `wasp start` again). In some cases it is neccesary for Tailwind to become functional in your app.
:::
### New project tree overview
```bash title="tree ." {6,13-14}
.

View File

@ -2172,7 +2172,7 @@ Any env vars defined in the `.env.server` / `.env.client` files will be forwarde
console.log(process.env.DATABASE_URL)
```
## Database
## Database configuration
Via `db` field of `app` declaration, you can configure the database used by Wasp.
@ -2184,7 +2184,10 @@ app MyApp {
system: PostgreSQL,
seeds: [
import devSeed from "@server/dbSeeds.js"
]
],
prisma: {
clientPreviewFeatures: ["extendedWhereUnique"]
}
}
}
```
@ -2192,7 +2195,7 @@ app MyApp {
`app.db` is a dictionary with following fields:
#### - `system: DbSystem` (Optional)
Database system that Wasp will use. It can be either `PostgreSQL` or `SQLite`.
The database system Wasp will use. It can be either `PostgreSQL` or `SQLite`.
If not defined, or even if whole `db` field is not present, default value is `SQLite`.
If you add/remove/modify `db` field, run `wasp db migrate-dev` to apply the changes.
@ -2200,6 +2203,12 @@ If you add/remove/modify `db` field, run `wasp db migrate-dev` to apply the chan
Defines seed functions that you can use via `wasp db seed` to seed your database with initial data.
Check out [Seeding](#seeding) section for more details.
#### - `prisma: [PrismaOptions]` (Optional)
Additional configuration for Prisma.
It currently only supports a single field:
- `clientPreviewFeatures : string` - allows you to define [Prisma client preview features](https://www.prisma.io/docs/concepts/components/preview-features/client-preview-features).
### SQLite
Default database is `SQLite`, since it is great for getting started with a new project (needs no configuring), but it can be used only in development - once you want to deploy Wasp to production you will need to switch to `PostgreSQL` and stick with it.
Check below for more details on how to migrate from SQLite to PostgreSQL.

View File

@ -9,7 +9,7 @@ const Announcement = () => {
let history = useHistory();
const handleLink = () => {
history.push('/blog/2023/06/22/wasp-launch-week-three')
history.push('/blog/2023/06/30/tutorial-jam')
//history.push('/#signup')
//window.open('https://twitter.com/MatijaSosic/status/1646532181324603395')
@ -39,7 +39,7 @@ const Announcement = () => {
`}
>
<span className='item-center flex gap-2 px-3'>
<span>🔮 Wasp Launch Week #3: Jun 26 - 30</span>
<span>📝 Join our Tutorial Jam #1 and win cool prizes!</span>
</span>
<span className='hidden items-center space-x-2 px-3 lg:flex'>

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB