Merge remote-tracking branch 'waspc-private/in-dir'

This commit is contained in:
Martin Sosic 2020-02-20 12:33:43 +01:00
commit 2ba0e89f05
107 changed files with 5453 additions and 0 deletions

6
waspc/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.stack-work/
waspc.cabal
*~
/.dir-locals.el
stack*.yaml.lock
/out

3
waspc/ChangeLog.md Normal file
View File

@ -0,0 +1,3 @@
# Changelog
## Unreleased changes

30
waspc/LICENSE Normal file
View File

@ -0,0 +1,30 @@
Copyright Author name here (c) 2019
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of Author name here nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

110
waspc/README.md Normal file
View File

@ -0,0 +1,110 @@
```
_ _
__ ____ _ ___ _ __ ___ | )/ )
\ \ /\ / / _` / __| '_ \ / __| \\ |//,' __
\ V V / (_| \__ \ |_) | (__ (")(_)-"()))=-
\_/\_/ \__,_|___/ .__/ \___| (\\
|_|
```
## Setup
Install `stack`.
Run `stack setup` in the project root to do initial setup.
## Project configuration overview
This is a [Stack](https://docs.haskellstack.org/en/stable/README/) project.
Stack installs GHC and packages automatically, in reproducible manner, in an isolated environment just for your Haskell project.
This project was created from `new-template` stack template and then modified inspired by `haskeleton` stack template.
Most important configuration file is `package.yaml`. Here we define what is what in our project, how is it built. Stack generates `waspc.cabal` from this document.
Also important is `stack.yaml` where we define Stack configuration for this project.
We provided some pieces of Stack documentation in this README to make it easier to start with the project, but for any more detailed and guaranteed up-to-date docs check Stack docs at https://docs.haskellstack.org/en/stable/README/.
### Adding a package as a dependency
This is just so you don't have to search Stack docs.
Just put it in package.yaml like this:
```
dependencies:
...
- <package_name>
...
```
If package you need is not in the Stack snapshot defined by `resolver`, add it to `extra-deps` instead of `dependencies`.
## Building / development
You build the project with `stack build`. It uses `package.yaml`, `waspc.yaml` (and possibly some other files in the future) and source files to generate files and build the project.
It is recommended using `stack build --pedantic` to turn on pedantic code checking (-Wall, -Werror).
`stack exec <my-executable>` will run executable in the context of stack project.
In our case, `stack exec waspc-cli` will run waspc (build it first with `stack build`!).
Some useful command options when building:
- `stack build --test` -> same as `stack test`.
- `stack build --file-watch` -> live watch, reruns every time a file changes.
- `stack build --pedantic` -> sets -Wall and -Werror ghc options.
- `stack build --ghc-options="-Wall -Werror"` -> sets ghc options.
- `stack build --bench`
- `stack build --profile`
- `stack build --trace`
There is also `stack install` which builds the project and then copies it to the local bin path.
Running `stack ghci` will open ghci in the context of the project, allowing you to load and run local modules, which can be useful for development.
You can use `stack clear` to clear all the generated files/artifacts from the project.
When developing, if you don't have full support for Haskell in your editor, consider using `stack build --file-watch` for live error checking, or `ghcid` as a faster alternative (install it globally with `stack install ghcid` and then just type `ghcid` when in the project.
### Run script
For more convenient running of common build/dev commands, we created `run` script.
It mostly runs stack commands described above.
The idea is that you normally use this for development, and you use `stack` directly when you need more control.
You can run `./run help` to learn how to use it.
Examples:
- `./run exec examples/todoMVC.wasp out/todoMVC` will run waspc on todoMVC example.
## Tests
For tests we are using [**Tasty**](https://documentup.com/feuerbach/tasty) testing framework. Tasty let's us combine different types of tests into a single test suite.
In Tasty, there is a main test file that is run when test suite is run. In that file we need to manually compose test tree out of tests that we wrote. We organize tests in test groups, which are then recursively grouped resulting in a test tree.
Cool thing is that we can organize tests this way however we want and also mix different type of tests (hspec, quickcheck, and whatever else want really).
Tests are normally split in files of course, so we need to import those all the way up to the main test file, however we organize our test groups/trees.
In order to avoid need for manual organization and importing of test files described above, we are using [tasty-discover](https://hackage.haskell.org/package/tasty-discover) which does this for us. It automatically detects files containing tests and then organizes them for us into test tree (and also takes care of importing). This means we only need to create a file, write tests in it and that is it.
Test functions however do need to be prefixed with special prefix to indicate which type of test are they: spec_ for Hspec, prop_ for QuickCheck and similar. Check docs for more details.
We can however still organize tests manually if we want in Tasty test trees, and then we just prefix them with test_ and tasty-discover will pick them up from there.
Additionally, currently we limited tasty-discover to auto-detect only files ending with Test.hs (*Test.hs glob). We might remove that requirement in the future if it proves to have no benefit.
For unit testing, we use **Hspec**.
For property testing, we use **Quickcheck**.
We additionally use **doctest** for testing code examples in documentation.
All tests go into `test/` directory. This is convention for Haskell, opposite to mixing them with source code as in Javascript for example. Not only that, but Haskell build tools don't have a good support for mixing them with source files, so even if we wanted to do that it is just not worth the hassle.
Test are run with `stack test`. You can do `stack test --coverage` to see the coverage.
## Benchmarking
For benchmarking we are using [**Criterion**](http://www.serpentine.com/criterion/).
You can run benchmark with `stack bench`.
## Other
Wasp ascii art used in the title is from https://www.asciiart.eu/animals/insects/bees, author: Stef00.

2
waspc/Setup.hs Normal file
View File

@ -0,0 +1,2 @@
import Distribution.Simple
main = defaultMain

34
waspc/app/Main.hs Normal file
View File

@ -0,0 +1,34 @@
module Main where
import System.Environment
import System.Directory
import qualified System.FilePath as FilePath
import Path ((</>), reldir)
import qualified Path
import qualified Path.Aliases as Path
import CompileOptions (CompileOptions (..))
import Lib (compile)
main :: IO ()
main = do
absCwdPath <- getCurrentDirectory >>= Path.parseAbsDir
args <- getArgs
case args of
[waspFilePath, outDirPath] -> do
absWaspFilePath <- Path.parseAbsFile (ensurePathIsAbs absCwdPath waspFilePath)
absOutDirPath <- Path.parseAbsDir (ensurePathIsAbs absCwdPath outDirPath)
-- TODO(martin): Take compile options as arguments to the command, right now I hardcoded the value.
let options = CompileOptions
{ externalCodeDirPath = (Path.parent absWaspFilePath) </> [reldir|ext|]
}
result <- compile absWaspFilePath absOutDirPath options
either putStrLn (\_ -> print ("Success!" :: String)) result
_ -> print ("Usage: ./waspc <wasp_file_path> <out_dir>" :: String)
where
-- | If path is not absolute, it is prefixed with given absolute directory.
ensurePathIsAbs :: Path.AbsDir -> FilePath -> FilePath
ensurePathIsAbs absDirPath path = if FilePath.isAbsolute path
then path
else (Path.toFilePath absDirPath) FilePath.</> path

6
waspc/benchmark/Main.hs Normal file
View File

@ -0,0 +1,6 @@
-- You can benchmark your code quickly and effectively with Criterion. See its
-- website for help: <http://www.serpentine.com/criterion/>.
import Criterion.Main
main :: IO ()
main = defaultMain [bench "const" (whnf const ())]

View File

@ -0,0 +1,69 @@
{{={{> <}}=}}
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
### Analyzing the Bundle Size
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
### Making a Progressive Web App
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
### Advanced Configuration
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
### Deployment
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
### `npm run build` fails to minify
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify

View File

@ -0,0 +1,24 @@
{{={{> <}}=}}
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,39 @@
{{={= =}=}}
{
"name": "{= app.name =}",
"version": "0.0.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.9.1",
"@reduxjs/toolkit": "^1.2.3",
"lodash": "^4.17.15",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.1",
"redux": "^4.0.5",
"uuid": "^3.4.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,53 @@
{{={{> <}}=}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
This is the font that Material-UI requires.
-->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>{{> app.title <}}</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{{={{> <}}=}}
{
"name": "{{> app.name <}}",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,46 @@
{{={= =}=}}
import React, { Component } from 'react'
import { connect } from 'react-redux'
{=# jsImports =}
import {= what =} from "{= from =}"
{=/ jsImports =}
{=# entities =}
import * as {= entityLowerName =}State from '{= entityStatePath =}'
import * as {= entityLowerName =}Actions from '{= entityActionsPath =}'
import {= entity.name =} from '{= entityClassPath =}'
{=# entityCreateForms =}
import {= entityForm.name =} from '{= path =}'
{=/ entityCreateForms =}
{=# entityLists =}
import {= entityList.name =} from '{= path =}'
{=/ entityLists =}
{=/ entities =}
{=# pageStylePath =}
import '{= pageStylePath =}'
{=/ pageStylePath =}
export class {= page.name =} extends Component {
// TODO: Add propTypes.
render() {
return (
{=& page.content =}
)
}
}
export default connect(state => ({
{=# entities =}
{= entityLowerName =}List: {= entityLowerName =}State.selectors.all(state)
{=/ entities =}
}), {
{=# entities =}
add{= entityUpperName =}: {= entityLowerName =}Actions.add,
update{= entityUpperName =}: {= entityLowerName =}Actions.update,
remove{= entityUpperName =}: {= entityLowerName =}Actions.remove
{=/ entities =}
})({= page.name =})

View File

@ -0,0 +1,29 @@
{{={= =}=}}
import uuidv4 from 'uuid/v4'
export default class {= entity.name =} {
_data = {}
constructor (data = {}) {
this._data = {
id: data.id || uuidv4(),
{=# entity.fields =}
{= name =}: data.{= name =},
{=/ entity.fields =}
}
}
get id () {
return this._data.id
}
{=# entity.fields =}
get {= name =} () {
return this._data.{= name =}
}
{=/ entity.fields =}
toData () {
return this._data
}
}

View File

@ -0,0 +1,4 @@
{{={= =}=}}
export const ADD = 'entities/{= entityLowerName =}/ADD'
export const UPDATE = 'entities/{= entityLowerName =}/UPDATE'
export const REMOVE = 'entities/{= entityLowerName =}/REMOVE'

View File

@ -0,0 +1,29 @@
{{={= =}=}}
import * as types from './actionTypes'
/**
* @param {{= entity.name =}} {= entityLowerName =}
*/
export const add = ({= entityLowerName =}) => ({
type: types.ADD,
data: {= entityLowerName =}.toData()
})
/**
* @param {String} id
* @param {Object} data - Partial data that will be merged with existing {= entityLowerName =}.
*/
export const update = (id, data) => ({
type: types.UPDATE,
id,
data
})
/**
* @param {String} id
*/
export const remove = (id) => ({
type: types.REMOVE,
id
})

View File

@ -0,0 +1,122 @@
{{={= =}=}}
import _ from 'lodash'
import React from 'react'
import PropTypes from 'prop-types'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Switch from '@material-ui/core/Switch'
import Button from '@material-ui/core/Button'
import TextField from '@material-ui/core/TextField'
import {= entityClassName =} from '../{= entityClassName =}'
export default class {= formName =} extends React.Component {
static propTypes = {
onCreate: PropTypes.func,
submitButtonLabel: PropTypes.string
}
state = {
fields: {
{=# formFields =}
{=# boolean =}
{= name =}: {= defaultValue =},
{=/ boolean =}
{=# string =}
{= name =}: '{= defaultValue =}',
{=/ string =}
{=/ formFields =}
}
}
setField = (name, valueOrFn) => {
this.setState(prevState => ({
fields: {
...prevState.fields,
[name]: _.isFunction(valueOrFn) ? valueOrFn(prevState) : valueOrFn
}
}))
}
resetAllFields = () => {
{=# formFields =}
{=# boolean =}
this.setField('{= name =}', {= defaultValue =})
{=/ boolean =}
{=# string =}
this.setField('{= name =}', '{= defaultValue =}')
{=/ string =}
{=/ formFields =}
}
toggleField = (name) => {
this.setField(name, prevState => !prevState.fields[name])
}
getField = (name) => {
return this.state.fields[name]
}
handleSubmit = () => {
this.props.onCreate(new {= entityClassName =}(this.state.fields))
this.resetAllFields()
}
render() {
return (
<div className={this.props.className}>
<form noValidate onSubmit={this.handleSubmit} action="javascript:void(0);">
{=# formFields =}
{=# boolean =}
{=# show =}
<div>
<FormControlLabel
{=# label =}
label="{= label =}"
{=/ label =}
control={
<Switch
checked={this.getField('{= name =}')}
onChange={() => this.toggleField('{= name =}')}
value="{= name =}"
/>
}
/>
</div>
{=/ show =}
{=/ boolean =}
{=# string =}
{=# show =}
<div>
<TextField
{=# label =}
label="{= label =}"
{=/ label =}
{=# placeholder =}
placeholder="{= placeholder =}"
{=/ placeholder =}
value={this.getField('{= name =}')}
onChange={event => this.setField('{= name =}', event.target.value)}
margin="normal"
fullWidth
InputLabelProps={{
shrink: true
}}
/>
</div>
{=/ show =}
{=/ string =}
{=/ formFields =}
{=# showSubmitButton =}
<div>
<Button type="submit" variant="contained" color="primary">
{this.props.submitButtonLabel || 'Submit'}
</Button>
</div>
{=/ showSubmitButton =}
</form>
</div>
)
}
}

View File

@ -0,0 +1,130 @@
{{={= =}=}}
import _ from 'lodash'
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import Paper from '@material-ui/core/Paper'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import Checkbox from '@material-ui/core/Checkbox'
import TextField from '@material-ui/core/TextField'
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
import * as {= entityLowerName =}State from '../state'
import * as {= entityLowerName =}Actions from '../actions'
import {= entityClassName =} from '../{= entityClassName =}'
export class {= listName =} extends React.Component {
static propTypes = {
editable: PropTypes.bool,
filter: PropTypes.func
}
state = {
{= entityBeingEditedStateVar =}: null
}
setAsBeingEdited = {= entityLowerName =} => this.setState({
{= entityBeingEditedStateVar =}: {= entityLowerName =}.id
})
isBeingEdited = {= entityLowerName =} =>
{= entityLowerName =}.id === this.state.{= entityBeingEditedStateVar =}
finishEditing = {= entityLowerName =} => {
if ({= entityLowerName =}.id === this.state.{= entityBeingEditedStateVar =})
this.setState({ {= entityBeingEditedStateVar =}: null })
}
{=! Render "render" functions for each field, if provided =}
{=# listFields =}
{=# render =}
{= renderFnName =} =
{=& render =}
{=/ render =}
{=/ listFields =}
render() {
const {= entityLowerName =}ListToShow = this.props.filter ?
{=! TODO(matija): duplication, we could extract entityLowerName_List =}
this.props.{= entityLowerName =}List.filter(this.props.filter) :
this.props.{= entityLowerName =}List
return (
<div className={this.props.className}>
<Paper>
<Table>
<TableHead{=^ showHeader =} style={{display: 'none'}}{=/ showHeader =}>
<TableRow>
{=# listFields =}
<TableCell width="{= widthAsPercent =}%">{= name =}</TableCell>
{=/ listFields =}
</TableRow>
</TableHead>
<TableBody>
{{= entityLowerName =}ListToShow.map(({= entityLowerName =}) => (
<TableRow key={{= entityLowerName =}.id}>
{=# listFields =}
{=# boolean =}
<TableCell>
<Checkbox
checked={{= entityLowerName =}.{= name =}}
color="default"
inputProps={{
'aria-label': 'checkbox'
}}
disabled={!this.props.editable}
onChange={e => this.props.update{= entityName =}(
{= entityLowerName =}.id, { '{= name =}': e.target.checked }
)}
/>
</TableCell>
{=/ boolean =}
{=# string =}
<ClickAwayListener onClickAway={() => this.finishEditing({= entityLowerName =}) }>
<TableCell
onDoubleClick={() => this.setAsBeingEdited({= entityLowerName =})}
>
{this.props.editable && this.isBeingEdited({= entityLowerName =}) ? (
<TextField
value={{= entityLowerName =}.{= name =}}
onChange={e => this.props.update{= entityName =}(
{= entityLowerName =}.id, { '{= name =}': e.target.value }
)}
/>
) : (
{=# render =}
this.{= renderFnName =}({= entityLowerName =})
{=/ render =}
{=^ render =}
{= entityLowerName =}.{= name =}
{=/ render =}
)}
</TableCell>
</ClickAwayListener>
{=/ string =}
{=/ listFields =}
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</div>
)
}
}
export default connect(state => ({
// Selectors
{= entityLowerName =}List: {= entityLowerName =}State.selectors.all(state)
}), {
// Actions
update{= entityName =}: {= entityLowerName =}Actions.update
})({= listName =})

View File

@ -0,0 +1,60 @@
{{={= =}=}}
import { createSelector } from 'reselect'
import {= entity.name =} from './{= entity.name =}'
import * as types from './actionTypes'
// We assume that root reducer of the app will put this reducer under
// key ROOT_REDUCER_KEY.
const ROOT_REDUCER_KEY = 'entities/{= entity.name =}'
const initialState = {
all: []
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case types.ADD:
return {
...state,
all: [ ...state.all, action.data ]
}
case types.UPDATE:
return {
...state,
all: state.all.map(
{= entityLowerName =} =>
{= entityLowerName =}.id === action.id
? { ...{= entityLowerName =}, ...action.data }
: {= entityLowerName =}
)
}
case types.REMOVE:
return {
...state,
all: state.all.filter(
{= entityLowerName =} => {= entityLowerName =}.id !== action.id
)
}
default:
return state
}
}
let selectors = {}
selectors.root = (state) => state[ROOT_REDUCER_KEY]
/**
* @returns {{= entity.name =}[]}
*/
selectors.all = createSelector(selectors.root, (state) => {
return state.all.map(data => new {= entity.name =}(data))
})
export { reducer, initialState, selectors, ROOT_REDUCER_KEY }

View File

@ -0,0 +1,15 @@
{{={{> <}}=}}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

View File

@ -0,0 +1,26 @@
{{={= =}=}}
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import router from './router'
import { configureStore } from './store'
import { rootReducer } from './reducers'
import * as serviceWorker from './serviceWorker'
import './index.css'
const store = configureStore(rootReducer)
ReactDOM.render(
<Provider store={store}>
{ router }
</Provider>,
document.getElementById('root')
)
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

View File

@ -0,0 +1,23 @@
{{={= =}=}}
import { combineReducers } from 'redux'
{=# entities =}
import * as {= entityLowerName =}State from '{= entityStatePath =}'
{=/ entities =}
const states = [
{=# entities =}
{= entityLowerName =}State,
{=/ entities =}
]
const keyToReducer = states.reduce((acc, state) => {
// We set the reducers here by using their ROOT_REDUCER_KEY, because their
// internal state implementations assume so.
return { ...acc, [state.ROOT_REDUCER_KEY]: state.reducer }
}, {})
export const rootReducer = combineReducers({
...keyToReducer
})

View File

@ -0,0 +1,20 @@
{{={= =}=}}
import React from 'react'
import { Route, BrowserRouter as Router } from 'react-router-dom'
{=# pages =}
import {= name =} from './{= name =}'
{=/ pages =}
const router = (
<Router>
<div>
{=# pages =}
<Route exact path="{= route =}" component={ {= name =} }/>
{=/ pages =}
</div>
</Router>
)
export default router

View File

@ -0,0 +1,136 @@
{{={{> <}}=}}
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

View File

@ -0,0 +1,27 @@
{{={= =}=}}
import * as RTK from '@reduxjs/toolkit'
import loggerMiddleware from './middleware/logger'
/**
* @param {Function} reducer - Redux reducer to be used for this store.
* @param {Object} [preloadedState] - State that this store will be initialized with.
* @returns {Object} Redux store, configured to suit our needs.
*/
export const configureStore = (reducer, preloadedState) => {
const middleware = [
...RTK.getDefaultMiddleware(),
loggerMiddleware
]
const enhancers = []
const store = RTK.configureStore({
reducer,
preloadedState,
middleware,
enhancers
})
return store
}

View File

@ -0,0 +1,14 @@
{{={= =}=}}
const logger = store => next => action => {
console.group(action.type)
console.info('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}
export default logger

View File

@ -0,0 +1,57 @@
div {
color: green;
}
button.selected {
border: 1px solid black;
}
.hidden {
visibility: hidden;
}
.todos {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.todos__container {
width: 450px;
}
.todos__toggleAndInput {
display: flex;
align-items: flex-end;
margin-bottom: 10px;
}
.todos__toggleButton {
height: 40px;
margin-bottom: 8px;
margin-left: 20px;
background-color: white;
border: none;
display: inline-block;
font-size: 24px;
cursor: pointer;
}
.todos__newTaskForm {
width: 100%;
margin-left: 10px;
}
.todos__footer {
width: 100%;
margin-top: 10px;
display: flex;
justify-content: space-between;
}
.todos__footer__filters .filter:not(:last-child) {
margin-right: 10px;
}

View File

@ -0,0 +1,119 @@
// As seen here, we can import npm packages used in code generated by Wasp, which will be clearly defined.
// In the future, we will of course also be able to specify additional packages as dependencies.
import React from 'react'
import { connect } from 'react-redux'
// As seen here, we can import specific components/code generated by Wasp.
// These will have well defined and documented APIs and paths.
// Note that Task, NewTaskForm and TaskList are generated based on the declarations
// we made in todoApp.wasp file.
import Task from '@wasp/entities/task/Task'
import NewTaskForm from '@wasp/entities/task/components/NewTaskForm'
import TaskList from '@wasp/entities/task/components/TaskList'
import * as taskState from '@wasp/entities/task/state.js'
import * as taskActions from '@wasp/entities/task/actions.js'
const TASK_FILTER_TYPES = Object.freeze({
ALL: 'all',
ACTIVE: 'active',
COMPLETED: 'completed'
})
const TASK_FILTERS = Object.freeze({
[TASK_FILTER_TYPES.ALL]: null,
[TASK_FILTER_TYPES.ACTIVE]: task => !task.isDone,
[TASK_FILTER_TYPES.COMPLETED]: task => task.isDone
})
class Todo extends React.Component {
// TODO: prop types.
state = {
taskFilterName: TASK_FILTER_TYPES.ALL
}
toggleIsDoneForAllTasks = () => {
const areAllDone = this.props.taskList.every(t => t.isDone)
this.props.taskList.map(t => this.props.updateTask(t.id, { isDone: !areAllDone }))
}
deleteCompletedTasks = () => {
this.props.taskList.map((t) => { if (t.isDone) this.props.removeTask(t.id) })
}
isAnyTaskCompleted = () => this.props.taskList.some(t => t.isDone)
isThereAnyTask = () => this.props.taskList.length > 0
TaskFilterButton = ({ filterType, label }) => (
<button
className={'filter ' + (this.state.taskFilterName === filterType ? 'selected' : '')}
onClick={() => this.setState({ taskFilterName: filterType })}
>
{label}
</button>
)
render = () => {
return (
<div className="todos">
<div className="todos__container">
<h1> Todos </h1>
<div className="todos__toggleAndInput">
<button
disabled={!this.isThereAnyTask()}
className="todos__toggleButton"
onClick={this.toggleIsDoneForAllTasks}>
</button>
<NewTaskForm
className="todos__newTaskForm"
onCreate={task => this.props.addTask(task)}
submitButtonLabel={'Create new task'}
/>
</div>
{ this.isThereAnyTask() && (<>
<TaskList
editable
filter={TASK_FILTERS[this.state.taskFilterName]}
/>
<div className="todos__footer">
<div className="todos__footer__itemsLeft">
{ this.props.taskList.filter(task => !task.isDone).length } items left
</div>
<div className="todos__footer__filters">
<this.TaskFilterButton filterType={TASK_FILTER_TYPES.ALL} label="All" />
<this.TaskFilterButton filterType={TASK_FILTER_TYPES.ACTIVE} label="Active" />
<this.TaskFilterButton filterType={TASK_FILTER_TYPES.COMPLETED} label="Completed" />
</div>
<div className="todos__footer__clearCompleted">
<button
className={this.isAnyTaskCompleted() ? '' : 'hidden' }
onClick={this.deleteCompletedTasks}>
Clear completed
</button>
</div>
</div>
</>)}
</div>
</div>
)
}
}
export default connect(state => ({
taskList: taskState.selectors.all(state)
}), {
addTask: taskActions.add,
updateTask: taskActions.update,
removeTask: taskActions.remove
})(Todo)

View File

@ -0,0 +1,50 @@
import Todo from "@ext/Todo.js" // Imports non-wasp code from external code dir (ext/).
entity Task {
isDone :: boolean,
description :: string
}
app todoApp {
title: "ToDo App"
}
page Main {
route: "/",
style: "@ext/Main.css",
// TODO: We need to make this nicer / more explicit, it is not clear where is this coming from (these props).
// Also, this wiring is not elegant.
// Here we use Todo React component that we imported at the beginning of this file.
content: {=jsx <Todo/> jsx=}
}
entity-form<Task> NewTaskForm {
fields: {
description: {
show: true,
label: none,
placeholder: "What needs to be done?"
},
isDone: {
show: false,
defaultValue: false
}
},
submit: {
onEnter: true,
button: { show: false }
}
}
entity-list<Task> TaskList {
showHeader: false,
fields: {
description: {
// The contract for render is that user must provide a function that:
// - Receives a task as an input.
// - Returns a React Node or something that can be rendered by JSX.
// - Does not depend on any outer context.
render: {=js (task) => task.isDone ? <s>{task.description}</s> : task.description js=}
}
}
}

View File

@ -0,0 +1,131 @@
// Goal of this file is to re-create a TODO app from http://todomvc.com
// This file has "advanced" features in the sense that they might not yet be implemented by Wasp: this is just a proposal.
app todoMVC {
title: "ToDo MVC"
}
entity Task {
description :: string,
isDone :: boolean
}
// IDEA: `@connect Task as taskList` -> this would make it more obvious what is available, also we don't need to automatically try to guess what to import.
page Main {
route: "/",
content: {=jsx
<div className="mainContainer">
<h1>todos</h1>
<div className="createTaskForm">
{/* Toggle all */}
<button onClick={() => {
const setIsDoneTo = false
if (this.props.taskList.some(t => !t.isDone)) setIsDoneTo = true
this.props.taskList.map(t => this.props.updateTask(t, { isDone: setIsDoneTo }))
}}>
<Icon id="arrow-down" />
</button>
<CreateTaskForm />
</div>
<div className="taskListContainer">
<TaskList filter={this.state.taskFilter} /> { /* Filter here -> that is not supported by TaskList yet. */ }
</div>
<div className="footer">
{ /* TODO: This is maybe not very nice, inlined like this.
Also, we directly address taskList here, while in TaskList and in CreateTaskForm we address
it implicitly. */ }
<span>
{ this.props.taskList.filter(task => !task.isDone).length } items left
</span>
{ /* TODO: Can we also make this nicer? */ }
<button onClick={() => this.setState({ taskFilter: () => true })}> All </button>
<button onClick={() => this.setState({ taskFilter: task => !task.isDone })}> Active </button>
<button onClick={() => this.setState({ taskFilter: task => task.isDone })}> Completed </button>
{/* Clear completed */}
{ this.props.taskList.some(t => t.isDone) &&
<button onClick={() => { this.state.taskList.map(t => if (t.isDone) this.props.deleteTask(t)) }}>Clear completed</button>
}
</div>
</div>
jsx=},
style: {=css
div {
color: green;
}
.mainContainer {
display: flex;
flex-direction: column;
align-items: center;
}
.taskListContainer {
width: 60%;
}
css=}
}
// TODO: This part is not currently supported at all.
entity-form<Task> CreateTaskForm {
fields: {
description: {
placeholder: "What do you want to do?"
},
isDone: {
show: false,
defaultValue: false // Although not shown, this field will be set to "false".
}
},
submit: {
button: { show: false },
onEnter: true,
// Resets fields to their initial values after the submission.
resetAllFields: true
}
}
// TODO: This part is not currently supported at all.
entity-list<Task> TaskList {
allowItemEditing: true,
allowItemDeletion: true, // Items can be deleted, and this also deletes them for real.
fields: {
description: {
// The contract for render is: user can provide a function that:
// - receives a task as an input
// - returns a React Node or something that can be rendered by JSX
// - does not depend on any outer context
render: {=js
(task) => {
if (task.isDone) return (<s>{task.description}</s>)
return task.description
}
js=}
}
}
}
// TODO: This part is not currently supported at all.
// Idea would be to generate a script (bash) that does deployment and would be called
// with `npm deploy`, from generated frontend.
// NOTE: For now we don't care about environments (staging) yet, there is just one environment to deploy to (production).
deployment {
frontend: {
// TODO: In case of github, this would go into CNAME file.
// NOTE: optional.
customDomain: 'todo-app.examples.wasp-lang.dev',
github: { // NOTE: For now we allow only one deployment method (in this case "github") at once.
branch: 'gh-pages' // NOTE: optional.
}
}
}

103
waspc/package.yaml Normal file
View File

@ -0,0 +1,103 @@
# This YAML file describes your package. Stack will automatically generate a
# Cabal file when you run `stack build`. See the hpack website for help with
# this file: <https://github.com/sol/hpack>.
name: waspc
version: 0.1.0.0
github: "Martinsos/waspc"
#license: BSD3
author: "wasp-lang"
maintainer: "sosic.martin@gmail.com, matija.sosic@gmail.com"
copyright: "2020 wasp-lang"
default-extensions:
- OverloadedStrings
- TemplateHaskell
- QuasiQuotes
- ScopedTypeVariables
extra-source-files:
- README.md
- ChangeLog.md
data-dir: data/
data-files:
- Generator/templates/**/*
# Metadata used when publishing your package
# synopsis: Short description of your package
# category: Web
# To avoid duplicated efforts in documentation and dealing with the
# complications of embedding Haddock markup inside cabal files, it is
# common to point users to the README.md file.
description: Please see the README on GitHub at <https://github.com/Martinsos/waspc#readme>
dependencies:
- base >= 4.7 && < 5
ghc-options:
- -Wall
library:
source-dirs: src
dependencies:
- filepath
- parsec
- mustache
- text
- aeson
- directory
- split
- unordered-containers
- path
- regex-compat
- time
executables:
waspc-cli:
main: Main.hs
source-dirs: app
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- waspc
- filepath
- path
- directory
benchmarks:
waspc-benchmarks:
main: Main.hs
source-dirs: benchmark
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- waspc
- criterion
tests:
waspc-test:
main: TastyDiscoverDriver.hs
source-dirs: test
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- waspc
- tasty
- tasty-hspec
- tasty-quickcheck
- tasty-discover
- QuickCheck
- aeson
- filepath
- text
- mtl
- parsec
- deepseq
- path

85
waspc/run Executable file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# This script defines common commands used during building / developing
# and makes it easy to run them.
THIS=$0
COMMAND=${1:-watch}
shift
ARGS="$@"
BOLD="\e[1m"
UNDERLINED="\e[4m"
RESET="\e[0m"
LIGHT_CYAN="\e[96m"
DEFAULT_COLOR="\e[39m"
BUILD_CMD="stack build"
WATCH_CMD="$BUILD_CMD --file-watch"
TEST_CMD="$BUILD_CMD --test"
TEST_WATCH_CMD="$TEST_CMD --file-watch"
EXEC_CMD="stack exec waspc-cli $ARGS"
GHCID_CMD="ghcid --command=stack ghci"
echo_and_eval () {
echo -e $"${LIGHT_CYAN}Running:${DEFAULT_COLOR}" $1 "\n"
$1
}
echo_bold () { echo -e $"${BOLD}${1}${RESET}"; }
print_usage () {
print_usage_cmd () {
echo -e $" ${UNDERLINED}${1}${RESET}"
echo " $2";
}
echo_bold "Usage: ${THIS} <command>"
echo "Commands:"
print_usage_cmd "build" \
"Builds the project."
print_usage_cmd "watch" \
"Builds the project on any file change. (DEFAULT)"
print_usage_cmd "test" \
"Builds the project and executes tests."
print_usage_cmd "test-watch" \
"Builds the project and executes tests on any file change."
print_usage_cmd "exec" \
"Builds the project once and runs the executable while forwarding arguments."
print_usage_cmd "ghcid" \
"Runs ghcid, which is much faster alternative to 'watch'."
print_usage_cmd "ghcid-test" \
"Runs ghcid on tests, which is much faster alternative to 'test-watch'."
}
case $COMMAND in
build)
echo_and_eval "$BUILD_CMD"
;;
watch)
echo_and_eval "$WATCH_CMD"
;;
ghcid)
echo_and_eval "$GHCID_CMD"
;;
ghcid-test)
# --color always is needed for Tasty to turn on the coloring.
# NOTE: I did not put this into variable because I was not able to put single quotes
# around :main --color always that way and it was not working.
ghcid -T=':main --color always' --command=stack ghci test/TastyDiscoverDriver.hs
;;
test)
echo_and_eval "$TEST_CMD"
;;
test-watch)
echo_and_eval "$TEST_WATCH_CMD"
;;
exec)
echo_and_eval "$BUILD_CMD"
echo
echo_and_eval "$EXEC_CMD"
;;
*)
print_usage
exit 1
esac

View File

@ -0,0 +1,12 @@
module CompileOptions
( CompileOptions(..)
) where
import qualified Path.Aliases as Path
-- TODO(martin): Should these be merged with Wasp data? Is it really a separate thing or not?
-- It would be easier to pass around if it is part of Wasp data. But is it semantically correct?
-- Maybe it is, even more than this!
data CompileOptions = CompileOptions
{ externalCodeDirPath :: !Path.AbsDir
}

59
waspc/src/ExternalCode.hs Normal file
View File

@ -0,0 +1,59 @@
module ExternalCode
( File
, getFilePathInExtCodeDir
, getFileText
, readFiles
) where
import qualified Data.Text.Lazy as TextL
import qualified Data.Text.Lazy.IO as TextL.IO
import Data.Text (Text)
import qualified Path
import qualified Path.Aliases as Path
import qualified Util.IO
data File = File
{ _pathInExtCodeDir :: !Path.RelFile -- ^ Path relative to external code directory.
, _text :: TextL.Text -- ^ File content. It will throw error when evaluated if file is not textual file.
}
instance Show File where
show = show . _pathInExtCodeDir
instance Eq File where
f1 == f2 = (_pathInExtCodeDir f1) == (_pathInExtCodeDir f2)
-- | Returns path relative to the external code directory.
getFilePathInExtCodeDir :: File -> Path.RelFile
getFilePathInExtCodeDir = _pathInExtCodeDir
-- | Unsafe method: throws error if text could not be read (if file is not a textual file)!
getFileText :: File -> Text
getFileText = TextL.toStrict . _text
-- | Returns all files contained in the specified external code dir, recursively.
-- File paths are relative to the specified external code dir path.
readFiles :: Path.AbsDir -> IO [File]
readFiles extCodeDirPath = do
relFilePaths <- Util.IO.listDirectoryDeep extCodeDirPath
let absFilePaths = map (extCodeDirPath Path.</>) relFilePaths
-- NOTE: We read text from all the files, regardless if they are text files or not, because
-- we don't know if they are a text file or not.
-- Since we do lazy reading (Text.Lazy), this is not a problem as long as we don't try to use
-- text of a file that is actually not a text file -> then we will get an error when Haskell
-- actually tries to read that file.
-- TODO: We are doing lazy IO here, and there is an idea of it being a thing to avoid, due to no
-- control over when resources are released and similar.
-- If we do figure out that this is causing us problems, we could do the following refactoring:
-- Don't read files at this point, just list them, and Wasp will contain just list of filepaths.
-- Modify TextFileDraft so that it also takes text transformation function (Text -> Text),
-- or create new file draft that will support that.
-- In generator, when creating TextFileDraft, give it function/logic for text transformation,
-- and it will be taken care of when draft will be written to the disk.
fileTexts <- mapM (TextL.IO.readFile . Path.toFilePath) absFilePaths
let files = map (\(path, text) -> File path text) (zip relFilePaths fileTexts)
return files

44
waspc/src/Generator.hs Normal file
View File

@ -0,0 +1,44 @@
module Generator
( writeWebAppCode
) where
import qualified Data.Text
import qualified Data.Text.IO
import Data.Time.Clock
import qualified Paths_waspc
import qualified Data.Version
import Control.Monad (mapM_)
import Path ((</>), relfile)
import qualified Path
import qualified Path.Aliases as Path
import CompileOptions (CompileOptions)
import Wasp (Wasp)
import Generator.Generators (generateWebApp)
import Generator.FileDraft (FileDraft, write)
-- | Generates web app code from given Wasp and writes it to given destination directory.
-- If dstDir does not exist yet, it will be created.
-- NOTE(martin): What if there is already smth in the dstDir? It is probably best
-- if we clean it up first? But we don't want this to end up with us deleting stuff
-- from user's machine. Maybe we just overwrite and we are good?
writeWebAppCode :: Wasp -> Path.AbsDir -> CompileOptions -> IO ()
writeWebAppCode wasp dstDir compileOptions = do
writeFileDrafts dstDir (generateWebApp wasp compileOptions)
writeDotWaspInfo dstDir
-- | Writes file drafts while using given destination dir as root dir.
-- TODO(martin): We could/should parallelize this.
-- We could also skip writing files that are already on the disk with same checksum.
writeFileDrafts :: Path.AbsDir -> [FileDraft] -> IO ()
writeFileDrafts dstDir fileDrafts = mapM_ (write dstDir) fileDrafts
-- | Writes .waspinfo, which contains some basic metadata about how/when wasp generated the code.
writeDotWaspInfo :: Path.AbsDir -> IO ()
writeDotWaspInfo dstDir = do
currentTime <- getCurrentTime
let version = Data.Version.showVersion Paths_waspc.version
let content = "Generated on " ++ (show currentTime) ++ " by waspc version " ++ (show version) ++ " ."
let dstPath = dstDir </> [relfile|.waspinfo|]
Data.Text.IO.writeFile (Path.toFilePath dstPath) (Data.Text.pack content)

View File

@ -0,0 +1,10 @@
module Generator.Common
( srcDirPath
) where
import Path (reldir)
import Path.Aliases as Path
-- | Path to src directory, relative to the root directory of generated code.
srcDirPath :: Path.RelDir
srcDirPath = [reldir|src|]

View File

@ -0,0 +1,104 @@
module Generator.Entity
( generateEntities
, entityDirPathInSrc
, entityClassPathInSrc
, entityStatePathInSrc
, entityActionsPathInSrc
-- EXPORTED FOR TESTING:
, generateEntityClass
, generateEntityState
, generateEntityActions
, generateEntityActionTypes
, generateEntityCreateForm
, entityTemplatesDirPath
) where
import Data.Maybe (fromJust)
import Path ((</>), relfile)
import qualified Path
import qualified Path.Aliases as Path
import Wasp
import Generator.FileDraft
import qualified Generator.Common as Common
import Generator.Entity.EntityForm (generateEntityCreateForm)
import Generator.Entity.EntityList (generateEntityList)
import Generator.Entity.Common
( entityTemplatesDirPath
, entityTemplateData
, entityDirPathInSrc
)
generateEntities :: Wasp -> [FileDraft]
generateEntities wasp = concat $ generateEntity wasp <$> getEntities wasp
generateEntity :: Wasp -> Entity -> [FileDraft]
generateEntity wasp entity =
[ generateEntityClass wasp entity
, generateEntityState wasp entity
, generateEntityActionTypes wasp entity
, generateEntityActions wasp entity
]
++ generateEntityComponents wasp entity
generateEntityClass :: Wasp -> Entity -> FileDraft
generateEntityClass wasp entity
= createSimpleEntityFileDraft wasp entity (entityClassPathInSrc entity) [relfile|_Entity.js|]
generateEntityState :: Wasp -> Entity -> FileDraft
generateEntityState wasp entity
= createSimpleEntityFileDraft wasp entity (entityStatePathInSrc entity) [relfile|state.js|]
generateEntityActionTypes :: Wasp -> Entity -> FileDraft
generateEntityActionTypes wasp entity
= createSimpleEntityFileDraft wasp entity (entityActionTypesPathInSrc entity) [relfile|actionTypes.js|]
generateEntityActions :: Wasp -> Entity -> FileDraft
generateEntityActions wasp entity
= createSimpleEntityFileDraft wasp entity (entityActionsPathInSrc entity) [relfile|actions.js|]
generateEntityComponents :: Wasp -> Entity -> [FileDraft]
generateEntityComponents wasp entity = concat
[ generateEntityCreateForms wasp entity
, generateEntityLists wasp entity
]
-- | Generates creation forms for the given entity.
generateEntityCreateForms :: Wasp -> Entity -> [FileDraft]
generateEntityCreateForms wasp entity = map (generateEntityCreateForm wasp) entityForms
where
entityForms = getEntityFormsForEntity wasp entity
-- | Generates list components for the given entity.
generateEntityLists :: Wasp -> Entity -> [FileDraft]
generateEntityLists wasp entity = map (generateEntityList wasp) entityLists
where
entityLists = getEntityListsForEntity wasp entity
-- | Helper function that captures common logic for generating entity file draft.
createSimpleEntityFileDraft :: Wasp -> Entity -> Path.RelFile -> Path.RelFile -> FileDraft
createSimpleEntityFileDraft wasp entity dstPathInSrc srcPathInEntityTemplatesDir
= createTemplateFileDraft dstPath srcPath (Just templateData)
where
srcPath = entityTemplatesDirPath </> srcPathInEntityTemplatesDir
dstPath = Common.srcDirPath </> dstPathInSrc
templateData = entityTemplateData wasp entity
-- * Paths of generated code (relative to src/ directory)
entityStatePathInSrc :: Entity -> Path.RelFile
entityStatePathInSrc entity = entityDirPathInSrc entity </> [relfile|state.js|]
entityActionsPathInSrc :: Entity -> Path.RelFile
entityActionsPathInSrc entity = entityDirPathInSrc entity </> [relfile|actions.js|]
entityActionTypesPathInSrc :: Entity -> Path.RelFile
entityActionTypesPathInSrc entity = entityDirPathInSrc entity </> [relfile|actionTypes.js|]
entityClassPathInSrc :: Entity -> Path.RelFile
entityClassPathInSrc entity = entityDirPathInSrc entity </>
(fromJust $ Path.parseRelFile $ (entityName entity) ++ ".js")

View File

@ -0,0 +1,75 @@
module Generator.Entity.Common
( entityTemplatesDirPath
, entityDirPathInSrc
, entityTemplateData
, entityComponentsDirPathInSrc
, entityFieldToJsonWithTypeAsKey
, addEntityFieldTypeToJsonAsKeyWithValueTrue
, getEntityLowerName
, getEntityClassName
) where
import Data.Maybe (fromJust)
import Data.Aeson ((.=), object)
import qualified Data.Aeson as Aeson
import qualified Data.Text as Text
import Path ((</>), reldir)
import qualified Path
import qualified Path.Aliases as Path
import qualified Util
import Wasp
-- | Path of the entity-related generated code, relative to src/ directory.
entityDirPathInSrc :: Entity -> Path.RelDir
entityDirPathInSrc entity = [reldir|entities|] </>
(fromJust $ Path.parseRelDir $ Util.camelToKebabCase (entityName entity))
-- | Path of the code generated for entity components, relative to src/ directory.
entityComponentsDirPathInSrc :: Entity -> Path.RelDir
entityComponentsDirPathInSrc entity = (entityDirPathInSrc entity) </> [reldir|components|]
-- | Location in templates where entity related templates reside.
entityTemplatesDirPath :: Path.RelDir
entityTemplatesDirPath = [reldir|src|] </> [reldir|entities|] </> [reldir|_entity|]
-- | Default generic data for entity templates.
entityTemplateData :: Wasp -> Entity -> Aeson.Value
entityTemplateData wasp entity = object
[ "wasp" .= wasp
, "entity" .= entity
, "entityLowerName" .= getEntityLowerName entity
-- TODO: use it also when creating Class file itself and in other files.
, "entityClassName" .= getEntityClassName entity
, "entityTypedFields" .= map entityFieldToJsonWithTypeAsKey (entityFields entity)
]
getEntityLowerName :: Entity -> String
getEntityLowerName = Util.toLowerFirst . entityName
getEntityClassName :: Entity -> String
getEntityClassName = Util.toUpperFirst . entityName
{- | Converts entity field to a JSON where field type is a key set to true, along with
all other field properties.
E.g.:
boolean field -> { boolean: true, type: "boolean", name: "isDone" }
string field -> { string: true, type: "string", name: "description"}
We need to have "boolean: true" part to achieve conditional rendering with Mustache - in
Mustache template we cannot check if type == "boolean", but only whether a "boolean" property
is set or not.
-}
entityFieldToJsonWithTypeAsKey :: EntityField -> Aeson.Value
entityFieldToJsonWithTypeAsKey entityField = addEntityFieldTypeToJsonAsKeyWithValueTrue
(entityFieldType entityField)
(Aeson.toJSON entityField)
-- | Adds "FIELD_TYPE: true" to a given json. This is needed for Mustache so we can differentiate
-- between the form fields of different types.
addEntityFieldTypeToJsonAsKeyWithValueTrue :: EntityFieldType -> Aeson.Value -> Aeson.Value
addEntityFieldTypeToJsonAsKeyWithValueTrue efType json =
Util.jsonSet (toText efType) (Aeson.toJSON True) json
where
toText = Text.pack . show

View File

@ -0,0 +1,144 @@
module Generator.Entity.EntityForm
( generateEntityCreateForm
-- For testing
, entityCreateFormPathInSrc
, FormFieldTemplateData(..)
) where
import Control.Exception (assert)
import Data.Aeson ((.=), object, ToJSON(..))
import Data.Maybe (fromJust)
import Path ((</>), reldir, relfile)
import qualified Path
import qualified Path.Aliases as Path
import qualified Util
import qualified Wasp as Wasp
import Wasp (Wasp)
import qualified Wasp.EntityForm as WEF
import qualified Generator.FileDraft as FD
import qualified Generator.Entity.Common as EC
import qualified Generator.Common as Common
-- | Data which will be fed to the Mustache "create form" template.
data EntityFormTemplateData = EntityFormTemplateData
{ _formName :: !String
, _entityClassName :: !String
, _formFields :: ![FormFieldTemplateData]
-- Submit
, _showSubmitButton :: !Bool
} deriving (Show)
instance ToJSON EntityFormTemplateData where
toJSON td = object
[ "formName" .= _formName td
, "entityClassName" .= _entityClassName td
, "formFields" .= _formFields td
, "showSubmitButton" .= _showSubmitButton td
]
-- | Represents template data for the individual form field.
data FormFieldTemplateData = FormFieldTemplateData
{ _fieldName :: !String
, _fieldType :: !Wasp.EntityFieldType
, _fieldShow :: !Bool
, _fieldDefaultValue :: !WEF.DefaultValue
, _fieldPlaceholder :: Maybe String
, _fieldLabel :: Maybe String
} deriving (Show)
instance ToJSON FormFieldTemplateData where
toJSON f = EC.addEntityFieldTypeToJsonAsKeyWithValueTrue (_fieldType f) $
object
[ "name" .= _fieldName f
, "type" .= _fieldType f
, "show" .= _fieldShow f
, "defaultValue" .= case (_fieldDefaultValue f) of
(WEF.DefaultValueString s) -> s
(WEF.DefaultValueBool b) -> Util.toLowerFirst $ show b
, "placeholder" .= _fieldPlaceholder f
, "label" .= _fieldLabel f
]
-- | Given entity and an entity form for it, creates a single data structure
-- with all the values needed by the template to generate the form.
createEntityFormTemplateData :: Wasp.Entity -> WEF.EntityForm -> EntityFormTemplateData
createEntityFormTemplateData entity entityForm =
assert (Wasp.entityName entity == WEF._entityName entityForm) $
EntityFormTemplateData
{ _formName = WEF._name entityForm
, _entityClassName = EC.getEntityClassName entity
, _formFields = map (createFormFieldTD entityForm) $ Wasp.entityFields entity
-- Submit
, _showSubmitButton = maybe True id maybeShowSubmitButton
}
where
maybeShowSubmitButton :: Maybe Bool
maybeShowSubmitButton = WEF._submit entityForm >>= WEF._submitButton >>= WEF._submitButtonShow
-- | Given field data from entity and an entity form for it, creates a single
-- data structure with all the values needed by the template to generate a form field.
createFormFieldTD :: WEF.EntityForm -> Wasp.EntityField -> FormFieldTemplateData
createFormFieldTD entityForm entityField = FormFieldTemplateData
{ _fieldName = Wasp.entityFieldName entityField
, _fieldType = Wasp.entityFieldType entityField
, _fieldShow = maybe True id $ formFieldConfig >>= WEF._fieldShow
, _fieldDefaultValue = maybe
defaultValueIfNothingInForm
id
$ formFieldConfig >>= WEF._fieldDefaultValue
, _fieldPlaceholder = formFieldConfig >>= WEF._fieldPlaceholder
, _fieldLabel = fieldLabel
}
where
-- Configuration of a form field within entity-form, if there is any.
formFieldConfig :: Maybe WEF.Field
formFieldConfig = WEF.getConfigForField entityForm entityField
getDefaultValueForFieldWithType :: Wasp.EntityFieldType -> WEF.DefaultValue
getDefaultValueForFieldWithType efType = case efType of
Wasp.EftString -> WEF.DefaultValueString ""
Wasp.EftBoolean -> WEF.DefaultValueBool False
-- If user did not explicitly set a default value, we determine it ourselves.
defaultValueIfNothingInForm :: WEF.DefaultValue
defaultValueIfNothingInForm =
getDefaultValueForFieldWithType $ Wasp.entityFieldType entityField
fieldLabel :: Maybe String
fieldLabel = case (formFieldConfig >>= WEF._fieldLabel) of
-- Label property is not provided -> in that case we set label to the
-- default value of entity field name (e.g. "description").
Nothing -> Just $ Wasp.entityFieldName entityField
-- Label property is provided and explicitly disabled ('label: none').
Just Nothing -> Nothing
-- Label property is provided and set to a specific value ('label: "something"').
Just (Just label) -> Just label
-- | Generates entity creation form.
generateEntityCreateForm :: Wasp -> WEF.EntityForm -> FD.FileDraft
generateEntityCreateForm wasp entityForm =
FD.createTemplateFileDraft dstPath templateSrcPath (Just templateData)
where
-- NOTE(matija): There should always be an entity in wasp for the given entity form.
-- If not, we want an error to be thrown.
entity = maybe
(error $ "Wasp must contain entity to which the entity form refers: " ++
WEF._entityName entityForm)
id
(Wasp.getEntityByName wasp (WEF._entityName entityForm))
templateSrcPath = EC.entityTemplatesDirPath </> [reldir|components|] </> [relfile|CreateForm.js|]
dstPath = Common.srcDirPath </> (entityCreateFormPathInSrc entity entityForm)
templateData = toJSON $ createEntityFormTemplateData entity entityForm
entityCreateFormPathInSrc :: Wasp.Entity -> WEF.EntityForm -> Path.RelFile
entityCreateFormPathInSrc entity entityForm =
EC.entityComponentsDirPathInSrc entity </>
(fromJust $ Path.parseRelFile $ (WEF._name entityForm) ++ ".js")

View File

@ -0,0 +1,134 @@
module Generator.Entity.EntityList
( generateEntityList
, entityListPathInSrc
) where
import Control.Exception (assert)
import Data.Aeson ((.=), object, ToJSON(..))
import Data.Maybe (fromJust)
import Path ((</>), reldir, relfile, parseRelFile)
import qualified Path.Aliases as Path
import qualified Util as U
import qualified Wasp
import Wasp (Wasp)
import qualified Wasp.EntityList as WEL
import qualified Wasp.JsCode
import qualified Generator.FileDraft as FD
import qualified Generator.Entity.Common as EC
import qualified Generator.Common as Common
data EntityListTemplateData = EntityListTemplateData
{ _listName :: !String
, _entityName :: !String
, _entityClassName :: !String
, _entityLowerName :: !String
, _listShowHeader :: !Bool
, _listFields :: ![ListFieldTemplateData]
, _entityBeingEditedStateVar :: !String
}
instance ToJSON EntityListTemplateData where
toJSON td = object
[ "listName" .= _listName td
, "entityName" .= _entityName td
, "entityClassName" .= _entityClassName td
, "entityLowerName" .= _entityLowerName td
, "showHeader" .= _listShowHeader td
, "listFields" .= _listFields td
, "entityBeingEditedStateVar" .= _entityBeingEditedStateVar td
]
data ListFieldTemplateData = ListFieldTemplateData
{ _fieldName :: !String
, _fieldType :: !Wasp.EntityFieldType
, _fieldWidthAsPercent :: !Int
-- Render
, _fieldRender :: Maybe Wasp.JsCode.JsCode
, _fieldRenderFnName :: String
}
instance ToJSON ListFieldTemplateData where
toJSON f = EC.addEntityFieldTypeToJsonAsKeyWithValueTrue (_fieldType f) $
object
[ "name" .= _fieldName f
, "type" .= _fieldType f
, "widthAsPercent" .= _fieldWidthAsPercent f
, "render" .= _fieldRender f
, "renderFnName" .= _fieldRenderFnName f
]
createEntityListTemplateData :: Wasp.Entity -> WEL.EntityList -> EntityListTemplateData
createEntityListTemplateData entity entityList =
assert (Wasp.entityName entity == WEL._entityName entityList) $
EntityListTemplateData
{ _listName = WEL._name entityList
, _entityName = Wasp.entityName entity
, _entityClassName = EC.getEntityClassName entity
, _entityLowerName = EC.getEntityLowerName entity
, _listShowHeader = showHeader
, _listFields = map (createListFieldTD entity entityList) $ Wasp.entityFields entity
, _entityBeingEditedStateVar = entityLowerName ++ "BeingEdited"
}
where
entityLowerName = EC.getEntityLowerName entity
showHeader = maybe True id (WEL._showHeader entityList)
createListFieldTD :: Wasp.Entity -> WEL.EntityList -> Wasp.EntityField -> ListFieldTemplateData
createListFieldTD entity entityList entityField = ListFieldTemplateData
{ _fieldName = Wasp.entityFieldName entityField
, _fieldType = Wasp.entityFieldType entityField
, _fieldWidthAsPercent = fieldWidthAsPercent
, _fieldRender = listFieldConfig >>= WEL._fieldRender
, _fieldRenderFnName = "render" ++ entityUpper ++ entityFieldUpper
}
where
-- Configuration of a form field within entity-list, if there is any.
listFieldConfig :: Maybe WEL.Field
listFieldConfig = WEL.getConfigForField entityList entityField
entityUpper = U.toUpperFirst $ Wasp.entityName entity
entityFieldUpper = U.toUpperFirst $ Wasp.entityFieldName entityField
-- NOTE(matija): for now we decide on width, it is not yet an option exposed to the
-- user. Our current strategy, since we have no outside info, is to make all fields
-- have approx. equal width.
--
-- Since we use Int, obviously we will not be able to always get a sum of 100. But with
-- the current style framework we are using (material-ui), it is not a problem.
--
-- E.g. if we have 3 fields, each of them will get assigned a width of 33 - material-ui
-- will then resolve that by itself - it will assign the missing width to one of the
-- fields (the last one, from what I tried).
fieldWidthAsPercent :: Int
fieldWidthAsPercent = 100 `div` (length $ Wasp.entityFields entity)
generateEntityList :: Wasp -> WEL.EntityList -> FD.FileDraft
generateEntityList wasp entityList =
FD.createTemplateFileDraft dstPath templateSrcPath (Just templateData)
where
-- NOTE(matija): There should always be an entity in wasp for the given entity list.
-- If not, we want an error to be thrown.
entity = maybe
(error $ "Wasp must contain entity to which the entity list refers: " ++
WEL._entityName entityList)
id
(Wasp.getEntityByName wasp (WEL._entityName entityList))
templateSrcPath = EC.entityTemplatesDirPath </> [reldir|components|] </> [relfile|List.js|]
dstPath = Common.srcDirPath </> (entityListPathInSrc entity entityList)
templateData = toJSON $ createEntityListTemplateData entity entityList
-- | Path in the generated src dir where the given entity list will be located.
entityListPathInSrc :: Wasp.Entity -> WEL.EntityList -> Path.RelFile
entityListPathInSrc entity entityList =
EC.entityComponentsDirPathInSrc entity </>
(fromJust $ parseRelFile $ (WEL._name entityList) ++ ".js")

View File

@ -0,0 +1,30 @@
module Generator.ExternalCode
( generateExternalCodeDir
) where
import qualified System.FilePath as FP
import qualified Path
import CompileOptions (CompileOptions)
import Wasp (Wasp)
import qualified Wasp
import qualified ExternalCode
import qualified Generator.FileDraft as FD
import qualified Generator.ExternalCode.Common as Common
import Generator.ExternalCode.Js (generateJsFile)
generateExternalCodeDir :: CompileOptions -> Wasp -> [FD.FileDraft]
generateExternalCodeDir compileOptions wasp =
map (generateFile compileOptions) (Wasp.getExternalCodeFiles wasp)
generateFile :: CompileOptions -> ExternalCode.File -> FD.FileDraft
generateFile compileOptions file
| extension `elem` [".js", ".jsx"] = generateJsFile file
| otherwise = FD.createCopyFileDraft (Common.getExtCodeFileDstPath file)
(Common.getExtCodeFileSrcPath compileOptions file)
where
extension = FP.takeExtension $ Path.toFilePath $ Common.getExtCodeFileSrcPath compileOptions file

View File

@ -0,0 +1,31 @@
module Generator.ExternalCode.Common
( externalCodeDirPathInSrc
, getExtCodeFileDstPath
, getExtCodeFileSrcPath
) where
import Path ((</>), reldir)
import qualified Path.Aliases as Path
import CompileOptions (CompileOptions)
import qualified CompileOptions
import qualified ExternalCode
import qualified Generator.Common as Common
externalCodeDirPathInSrc :: Path.RelDir
externalCodeDirPathInSrc = [reldir|ext-src|]
-- | Returns path where external code file will be generated,
-- relative to the root of the generated project.
getExtCodeFileDstPath :: ExternalCode.File -> Path.RelFile
getExtCodeFileDstPath file = Common.srcDirPath </> externalCodeDirPathInSrc </>
ExternalCode.getFilePathInExtCodeDir file
-- | Returns absolute path of the original external code file.
getExtCodeFileSrcPath :: CompileOptions -> ExternalCode.File -> Path.AbsFile
getExtCodeFileSrcPath compileOptions file = CompileOptions.externalCodeDirPath compileOptions </>
ExternalCode.getFilePathInExtCodeDir file

View File

@ -0,0 +1,44 @@
module Generator.ExternalCode.Js
( generateJsFile
-- FOR TESTING:
, resolveJsFileWaspImports
) where
import qualified Text.Regex as TR
import Data.Text (Text, pack, unpack)
import Path ((</>))
import qualified Path
import qualified Path.Aliases as Path
import qualified Path.Extra as Path
import qualified Generator.FileDraft as FD
import qualified ExternalCode
import qualified Generator.ExternalCode.Common as Common
generateJsFile :: ExternalCode.File -> FD.FileDraft
generateJsFile file = FD.createTextFileDraft (Common.getExtCodeFileDstPath file) text'
where
text = ExternalCode.getFileText file
text' = resolveJsFileWaspImports jsFilePathInSrcDir text
jsFilePathInSrcDir = Common.externalCodeDirPathInSrc </>
ExternalCode.getFilePathInExtCodeDir file
-- | Takes a file path where the external code js file will be generated, relative to generated src dir.
-- Also takes text of the file. Returns text where special @wasp imports have been replaced with
-- imports that will work.
-- TODO: I had hard time finding popular libraries for more advanced replacements, so for now
-- I just used very simple regex replacement, which might not work in some complicated situations
-- (it will also match on commens and strings and similar).
-- For the future, we should probably use some kind of better regex or even some kind of parser.
-- Possible candidates: replace-attoparsec.
resolveJsFileWaspImports :: Path.RelFile -> Text -> Text
resolveJsFileWaspImports jsFilePathInSrcDir jsFileText = pack $
-- NOTE(matija): we could not use "\\s+" in the regex below because it does not
-- work on OS X for some unknown reason. This is why we use " +" instead.
-- Maybe we should user another regex library, e.g. regexec from
-- https://hackage.haskell.org/package/regex-posix-0.96.0.0/docs/Text-Regex-Posix-String.html
TR.subRegex (TR.mkRegex "(from +['\"])@wasp/")
(unpack jsFileText)
("\\1" ++ Path.reversePath (Path.parent jsFilePathInSrcDir) ++ "/")

View File

@ -0,0 +1,53 @@
module Generator.FileDraft
( FileDraft(..)
, Writeable(..)
, createTemplateFileDraft
, createCopyFileDraft
, createTextFileDraft
) where
import qualified Data.Aeson as Aeson
import Data.Text (Text)
import qualified Path.Aliases as Path
import Generator.FileDraft.Writeable
import Generator.FileDraft.TemplateFileDraft (TemplateFileDraft)
import qualified Generator.FileDraft.TemplateFileDraft as TmplFD
import Generator.FileDraft.CopyFileDraft (CopyFileDraft)
import qualified Generator.FileDraft.CopyFileDraft as CopyFD
import Generator.FileDraft.TextFileDraft (TextFileDraft)
import qualified Generator.FileDraft.TextFileDraft as TextFD
-- | FileDraft unites different file draft types into a single type,
-- so that in the rest of the system they can be passed around as heterogeneous
-- collection when needed.
data FileDraft
= FileDraftTemplateFd TemplateFileDraft
| FileDraftCopyFd CopyFileDraft
| FileDraftTextFd TextFileDraft
deriving (Show, Eq)
instance Writeable FileDraft where
write dstDir (FileDraftTemplateFd draft) = write dstDir draft
write dstDir (FileDraftCopyFd draft) = write dstDir draft
write dstDir (FileDraftTextFd draft) = write dstDir draft
createTemplateFileDraft :: Path.RelFile -> Path.RelFile -> Maybe Aeson.Value -> FileDraft
createTemplateFileDraft dstPath tmplSrcPath tmplData =
FileDraftTemplateFd $ TmplFD.TemplateFileDraft { TmplFD._dstPath = dstPath
, TmplFD._srcPathInTmplDir = tmplSrcPath
, TmplFD._tmplData = tmplData
}
createCopyFileDraft :: Path.RelFile -> Path.AbsFile -> FileDraft
createCopyFileDraft dstPath srcPath =
FileDraftCopyFd $ CopyFD.CopyFileDraft { CopyFD._dstPath = dstPath, CopyFD._srcPath = srcPath}
createTextFileDraft :: Path.RelFile -> Text -> FileDraft
createTextFileDraft dstPath content =
FileDraftTextFd $ TextFD.TextFileDraft { TextFD._dstPath = dstPath, TextFD._content = content}

View File

@ -0,0 +1,24 @@
module Generator.FileDraft.CopyFileDraft
( CopyFileDraft(..)
) where
import qualified Path
import qualified Path.Aliases as Path
import Generator.FileDraft.Writeable
import Generator.FileDraft.WriteableMonad
-- | File draft based purely on another file, that is just copied.
data CopyFileDraft = CopyFileDraft
{ _dstPath :: !Path.RelFile -- ^ Path of file to be written, relative to some root dir.
, _srcPath :: !Path.AbsFile -- ^ Absolute path of source file to copy.
}
deriving (Show, Eq)
instance Writeable CopyFileDraft where
write absDstDirPath draft = do
createDirectoryIfMissing True (Path.toFilePath $ Path.parent absDraftDstPath)
copyFile (Path.toFilePath $ _srcPath draft) (Path.toFilePath $ absDraftDstPath)
where
absDraftDstPath = absDstDirPath Path.</> (_dstPath draft)

View File

@ -0,0 +1,33 @@
module Generator.FileDraft.TemplateFileDraft
( TemplateFileDraft(..)
) where
import qualified Data.Aeson as Aeson
import qualified Path
import qualified Path.Aliases as Path
import Generator.FileDraft.Writeable
import Generator.FileDraft.WriteableMonad
-- | File draft based on template file that gets combined with data.
data TemplateFileDraft = TemplateFileDraft
{ _dstPath :: !Path.RelFile -- ^ Path of file to be written, relative to some dst root dir.
, _srcPathInTmplDir :: !Path.RelFile -- ^ Path of template source file, relative to templates root dir.
, _tmplData :: Maybe Aeson.Value -- ^ Data to be fed to the template while rendering it.
}
deriving (Show, Eq)
instance Writeable TemplateFileDraft where
write absDstDirPath draft = do
createDirectoryIfMissing True (Path.toFilePath $ Path.parent absDraftDstPath)
case _tmplData draft of
Nothing -> do
absDraftSrcPath <- getTemplateFileAbsPath (_srcPathInTmplDir draft)
copyFile (Path.toFilePath absDraftSrcPath) (Path.toFilePath absDraftDstPath)
Just tmplData -> do
content <- compileAndRenderTemplate (_srcPathInTmplDir draft) tmplData
writeFileFromText (Path.toFilePath absDraftDstPath) content
where
absDraftDstPath :: Path.AbsFile
absDraftDstPath = absDstDirPath Path.</> (_dstPath draft)

View File

@ -0,0 +1,26 @@
module Generator.FileDraft.TextFileDraft
( TextFileDraft(..)
) where
import qualified Path
import qualified Path.Aliases as Path
import Generator.FileDraft.Writeable
import Generator.FileDraft.WriteableMonad
import Data.Text (Text)
-- | File draft based on text, that is to be written to file when time comes.
data TextFileDraft = TextFileDraft
{ _dstPath :: !Path.RelFile -- ^ Path of file to be written, relative to some root dir.
, _content :: Text
}
deriving (Show, Eq)
instance Writeable TextFileDraft where
write dstDir draft = do
createDirectoryIfMissing True (Path.toFilePath $ Path.parent absDraftDstPath)
writeFileFromText (Path.toFilePath absDraftDstPath) (_content draft)
where
absDraftDstPath = dstDir Path.</> (_dstPath draft)

View File

@ -0,0 +1,14 @@
module Generator.FileDraft.Writeable
( Writeable(..)
) where
import qualified Path.Aliases as Path
import Generator.FileDraft.WriteableMonad
class Writeable w where
-- | Write file somewhere in the provided dst directory.
write :: (WriteableMonad m)
=> Path.AbsDir -- ^ Absolute path of dst directory.
-> w
-> m ()

View File

@ -0,0 +1,56 @@
module Generator.FileDraft.WriteableMonad
( WriteableMonad(..)
) where
import qualified System.Directory
import qualified Data.Text.IO
import Data.Aeson as Aeson
import Data.Text (Text)
import qualified Path.Aliases as Path
import qualified Generator.Templates
-- TODO: Should we use DI via data instead of typeclasses?
-- https://news.ycombinator.com/item?id=10392044
-- TODO: Should we make constraint MonadIO instead of just Monad?
-- That would allow us to do liftIO. And that might allow us to perform any IO
-- we want (hm will it?), which could be useful for custom stuff (but does that defeat the whole purpose?).
-- But that means we can't test that part, which yes, defeats the purpose somewhat.
-- I feel like all together we should not do it :), but it is an option if needed.
-- | Describes effects needed by File Drafts.
class (Monad m) => WriteableMonad m where
createDirectoryIfMissing
:: Bool -- ^ True if parents should also be created.
-> FilePath -- ^ Path to the directory to create.
-> m ()
copyFile
:: FilePath -- ^ Src path.
-> FilePath -- ^ Dst path.
-> m ()
writeFileFromText :: FilePath -> Text -> m ()
getTemplateFileAbsPath
:: Path.RelFile -- ^ Template file path, relative to templates root directory.
-> m Path.AbsFile
-- | Returns absolute path of templates root directory.
getTemplatesDirAbsPath :: m Path.AbsDir
compileAndRenderTemplate
:: Path.RelFile -- ^ Path to the template file, relative to template root dir.
-> Aeson.Value -- ^ JSON to be provided as template data.
-> m Text
instance WriteableMonad IO where
createDirectoryIfMissing = System.Directory.createDirectoryIfMissing
copyFile = System.Directory.copyFile
writeFileFromText = Data.Text.IO.writeFile
getTemplateFileAbsPath = Generator.Templates.getTemplateFileAbsPath
getTemplatesDirAbsPath = Generator.Templates.getTemplatesDirAbsPath
compileAndRenderTemplate = Generator.Templates.compileAndRenderTemplate

View File

@ -0,0 +1,88 @@
module Generator.Generators
( generateWebApp
) where
import Data.Aeson ((.=), object, ToJSON(..))
import Path ((</>), reldir, relfile)
import qualified Path
import qualified Path.Aliases as Path
import CompileOptions (CompileOptions)
import qualified Util
import Wasp
import Generator.FileDraft
import qualified Generator.Entity as EntityGenerator
import qualified Generator.PageGenerator as PageGenerator
import qualified Generator.ExternalCode as ExternalCodeGenerator
import qualified Generator.Common as Common
generateWebApp :: Wasp -> CompileOptions -> [FileDraft]
generateWebApp wasp options = concatMap ($ wasp)
[ generateReadme
, generatePackageJson
, generateGitignore
, generatePublicDir
, generateSrcDir
, ExternalCodeGenerator.generateExternalCodeDir options
]
generateReadme :: Wasp -> [FileDraft]
generateReadme wasp = [simpleTemplateFileDraft [relfile|README.md|] wasp]
generatePackageJson :: Wasp -> [FileDraft]
generatePackageJson wasp = [simpleTemplateFileDraft [relfile|package.json|] wasp]
generateGitignore :: Wasp -> [FileDraft]
generateGitignore wasp = [createTemplateFileDraft [relfile|.gitignore|] [relfile|gitignore|] (Just $ toJSON wasp)]
generatePublicDir :: Wasp -> [FileDraft]
generatePublicDir wasp =
createTemplateFileDraft ([reldir|public|] </> [relfile|favicon.ico|])
([reldir|public|] </> [relfile|favicon.ico|])
Nothing
: map (\path -> simpleTemplateFileDraft ([reldir|public|] </> path) wasp)
[ [relfile|index.html|]
, [relfile|manifest.json|]
]
-- * Src dir
generateSrcDir :: Wasp -> [FileDraft]
generateSrcDir wasp
= (createTemplateFileDraft (Common.srcDirPath </> [relfile|logo.png|])
([reldir|src|] </> [relfile|logo.png|])
Nothing)
: map (\path -> simpleTemplateFileDraft ([reldir|src|] </> path) wasp)
[ [relfile|index.js|]
, [relfile|index.css|]
, [relfile|router.js|]
, [relfile|serviceWorker.js|]
, [reldir|store|] </> [relfile|index.js|]
, [reldir|store|] </> [reldir|middleware|] </> [relfile|logger.js|]
]
++ PageGenerator.generatePages wasp
++ EntityGenerator.generateEntities wasp
++ [generateReducersJs wasp]
generateReducersJs :: Wasp -> FileDraft
generateReducersJs wasp = createTemplateFileDraft dstPath srcPath (Just templateData)
where
srcPath = [reldir|src|] </> [relfile|reducers.js|]
dstPath = Common.srcDirPath </> [relfile|reducers.js|]
templateData = object
[ "wasp" .= wasp
, "entities" .= map toEntityData (getEntities wasp)
]
toEntityData entity = object
[ "entity" .= entity
, "entityLowerName" .= (Util.toLowerFirst $ entityName entity)
, "entityStatePath" .= ("./" ++ (Path.toFilePath $ EntityGenerator.entityStatePathInSrc entity))
]
-- * Helpers
-- | Creates template file draft that uses given path as both src and dst path
-- and wasp as template data.
simpleTemplateFileDraft :: Path.RelFile -> Wasp -> FileDraft
simpleTemplateFileDraft path wasp = createTemplateFileDraft path path (Just $ toJSON wasp)

View File

@ -0,0 +1,127 @@
module Generator.PageGenerator
( generatePages
-- EXPORTED ONLY FOR TESTS:
, generatePage
, generatePageComponent
, generatePageStyle
) where
import Data.Maybe (fromJust)
import Data.Aeson ((.=), object)
import qualified Data.Aeson as Aeson
import qualified System.FilePath as FP
import Path ((</>), relfile, reldir)
import qualified Path
import qualified Path.Aliases as Path
import qualified Path.Extra as Path
import qualified Util
import Wasp (Wasp)
import qualified Wasp
import qualified Wasp.Page as WP
import qualified Wasp.Style as WStyle
import qualified Wasp.JsImport as WJsImport
import qualified Wasp.Entity as WEntity
import Generator.FileDraft
import qualified Generator.Entity as EntityGenerator
import qualified Generator.Entity.EntityForm as GEF
import qualified Generator.Entity.EntityList as GEL
import Generator.ExternalCode.Common (externalCodeDirPathInSrc)
import qualified Generator.Common as Common
generatePages :: Wasp -> [FileDraft]
generatePages wasp = concatMap (generatePage wasp) (Wasp.getPages wasp)
generatePage :: Wasp -> WP.Page -> [FileDraft]
generatePage wasp page =
[ generatePageComponent wasp page
]
++ maybe [] fst (generatePageStyle wasp page)
generatePageComponent :: Wasp -> WP.Page -> FileDraft
generatePageComponent wasp page = createTemplateFileDraft dstPath srcPath (Just templateData)
where
srcPath = [reldir|src|] </> [relfile|_Page.js|]
dstPath = Common.srcDirPath </> pageDirPathInSrc </> (fromJust $ Path.parseRelFile $ (WP.pageName page) ++ ".js")
templateData = object $
[ "wasp" .= wasp
, "page" .= page
, "entities" .= map toEntityData (Wasp.getEntities wasp)
, "jsImports" .= map toJsImportData (Wasp.getJsImports wasp)
]
++ maybe []
(\(_, path) -> ["pageStylePath" .= (buildImportPathFromPathInSrc path)])
(generatePageStyle wasp page)
toEntityData entity = object
[ "entity" .= entity
, "entityLowerName" .= (Util.toLowerFirst $ WEntity.entityName entity)
, "entityUpperName" .= (Util.toUpperFirst $ WEntity.entityName entity)
, "entityStatePath" .=
(buildImportPathFromPathInSrc $ EntityGenerator.entityStatePathInSrc entity)
, "entityActionsPath" .=
(buildImportPathFromPathInSrc $ EntityGenerator.entityActionsPathInSrc entity)
, "entityClassPath" .=
(buildImportPathFromPathInSrc $ EntityGenerator.entityClassPathInSrc entity)
, "entityCreateForms" .= map toEntityFormData entityForms
, "entityLists" .= map toEntityListData entityLists
]
where
-- Entity forms
entityForms = Wasp.getEntityFormsForEntity wasp entity
generateEntityFormPath entityForm =
buildImportPathFromPathInSrc $ GEF.entityCreateFormPathInSrc entity entityForm
toEntityFormData entityForm = object
[ "entityForm" .= entityForm
, "path" .= generateEntityFormPath entityForm
]
-- Entity list
entityLists = Wasp.getEntityListsForEntity wasp entity
generateEntityListPath entityList =
buildImportPathFromPathInSrc $ GEL.entityListPathInSrc entity entityList
toEntityListData entityList = object
[ "entityList" .= entityList
, "path" .= generateEntityListPath entityList
]
toJsImportData :: WJsImport.JsImport -> Aeson.Value
toJsImportData jsImport = object
[ "what" .= WJsImport.jsImportWhat jsImport
-- NOTE: Here we assume that "from" is relative to external code dir path.
-- If this part will be reused, consider externalizing this assumption, so we don't have it on multiple places.
, "from" .= (buildImportPathFromPathInSrc $
externalCodeDirPathInSrc </> (WJsImport.jsImportFrom jsImport))
]
pageDirPathInSrc :: Path.RelDir
pageDirPathInSrc = [reldir|.|]
relPathFromPageToSrc :: FilePath
relPathFromPageToSrc = Path.reversePath pageDirPathInSrc
-- | Takes path relative to the src path of generated project and turns it into relative path that can be
-- used as "from" part of the import in the Page source file.
-- NOTE: Here we return FilePath instead of Path because we need stuff like "./" or "../" in the path,
-- which Path would normalize away.
buildImportPathFromPathInSrc :: Path.Path Path.Rel a -> FilePath
buildImportPathFromPathInSrc pathInSrc = relPathFromPageToSrc FP.</> (Path.toFilePath pathInSrc)
-- Returns file draft(s) that need to be created (if any) +
-- file path via which to import the style (relative to generated src dir).
generatePageStyle :: Wasp -> WP.Page -> Maybe ([FileDraft], Path.RelFile)
generatePageStyle _ page = makeDraftsAndPath <$> WP.pageStyle page
where
makeDraftsAndPath :: WStyle.Style -> ([FileDraft], Path.RelFile)
makeDraftsAndPath (WStyle.CssCode code) =
let stylePathInSrcDir = fromJust $ Path.parseRelFile $ (WP.pageName page) ++ ".css"
draftDstPath = Common.srcDirPath </> stylePathInSrcDir
in ( [createTextFileDraft draftDstPath code]
, fromJust $ Path.parseRelFile $ (WP.pageName page) ++ ".css"
)
makeDraftsAndPath (WStyle.ExtCodeCssFile pathInExtCodeDir) =
([], externalCodeDirPathInSrc </> pathInExtCodeDir)

View File

@ -0,0 +1,86 @@
module Generator.Templates
( getTemplatesDirAbsPath
, getTemplateFileAbsPath
, compileAndRenderTemplate
) where
import qualified Text.Mustache as Mustache
import Text.Mustache.Render (SubstitutionError(..))
import qualified Data.Aeson as Aeson
import Data.Text (Text)
import Text.Printf (printf)
import Path ((</>), reldir)
import qualified Path
import qualified Path.Aliases as Path
import qualified Paths_waspc
-- TODO: Write tests for this file! But first we need to decouple logic from IO
-- so that we can mock it.
-- | Returns absolute path of templates root directory.
-- NOTE(Martin): Here I set it to react-app, which might be one lvl too deep
-- and will require some changes in the future, but did not want to
-- overengineer for now.
getTemplatesDirAbsPath :: IO Path.AbsDir
getTemplatesDirAbsPath = do
absDataDirPath <- Paths_waspc.getDataDir >>= Path.parseAbsDir
return $ absDataDirPath </> templatesDirPathInDataDir
-- | Takes template file path relative to templates root directory and returns
-- its absolute path.
getTemplateFileAbsPath :: Path.RelFile -> IO Path.AbsFile
getTemplateFileAbsPath tmplFilePathInTemplatesDir =
Paths_waspc.getDataFileName (Path.toFilePath tmplFilePathInDataDir) >>= Path.parseAbsFile
where
tmplFilePathInDataDir = templatesDirPathInDataDir </> tmplFilePathInTemplatesDir
-- | NOTE(Martin): Here I set it to react-app, which might be one lvl too deep
-- and will require some changes in the future, but did not want to
-- overengineer for now.
templatesDirPathInDataDir :: Path.RelDir
templatesDirPathInDataDir = reactAppTemplatesDirPathInDataDir
reactAppTemplatesDirPathInDataDir :: Path.RelDir
reactAppTemplatesDirPathInDataDir = [reldir|Generator|] </> [reldir|templates|] </> [reldir|react-app|]
compileAndRenderTemplate
:: Path.RelFile -- ^ Path to the template file, relative to templates root dir.
-> Aeson.Value -- ^ JSON to be provided as template data.
-> IO Text
compileAndRenderTemplate relTmplPath tmplData = do
mustacheTemplate <- compileMustacheTemplate relTmplPath
renderMustacheTemplate mustacheTemplate tmplData
compileMustacheTemplate
:: Path.RelFile -- ^ Path to the template file, relative to templates root dir.
-> IO Mustache.Template
compileMustacheTemplate relTmplPath = do
templatesDirAbsPath <- getTemplatesDirAbsPath
absTmplPath <- getTemplateFileAbsPath relTmplPath
eitherTemplate <- Mustache.automaticCompile [Path.toFilePath templatesDirAbsPath] (Path.toFilePath absTmplPath)
return $ either raiseCompileError id eitherTemplate
where
raiseCompileError err = error $ -- TODO: Handle these errors better?
printf "Compilation of template %s failed. %s" (show relTmplPath) (show err)
areAllErrorsSectionDataNotFound :: [SubstitutionError] -> Bool
areAllErrorsSectionDataNotFound subsErrors = all isSectionDataNotFoundError subsErrors
where
isSectionDataNotFoundError e = case e of
SectionTargetNotFound _ -> True
_ -> False
renderMustacheTemplate :: Mustache.Template -> Aeson.Value -> IO Text
renderMustacheTemplate mustacheTemplate templateData = do
let mustacheTemplateData = Mustache.toMustache templateData
let (errors, fileText) =
Mustache.checkedSubstituteValue mustacheTemplate mustacheTemplateData
-- NOTE(matija): Mustache reports errors when object does
-- not have a property specified in the template, which we use to implement
-- conditionals. This is why we ignore these errors.
if (null errors) || (areAllErrorsSectionDataNotFound errors)
then (return fileText)
else (error $ "Unexpected errors occured while rendering template: "
++ (show errors))

117
waspc/src/Lexer.hs Normal file
View File

@ -0,0 +1,117 @@
module Lexer where
import Text.Parsec (letter, alphaNum, (<|>), char, between)
import Text.Parsec.String (Parser)
import Text.Parsec.Language (emptyDef)
import qualified Text.Parsec.Token as Token
reservedNameImport :: String
reservedNameImport = "import"
reservedNameFrom :: String
reservedNameFrom = "from"
-- * Wasp element types
reservedNameApp :: String
reservedNameApp = "app"
reservedNamePage :: String
reservedNamePage = "page"
reservedNameEntity :: String
reservedNameEntity = "entity"
reservedNameEntityForm :: String
reservedNameEntityForm = "entity-form"
reservedNameEntityList :: String
reservedNameEntityList = "entity-list"
-- * Data types.
reservedNameString :: String
reservedNameString = "string"
reservedNameBoolean :: String
reservedNameBoolean = "boolean"
reservedNameBooleanTrue :: String
reservedNameBooleanTrue = "true"
reservedNameBooleanFalse :: String
reservedNameBooleanFalse = "false"
reservedNames :: [String]
reservedNames =
[ reservedNameImport
, reservedNameFrom
-- * Wasp element types
, reservedNameApp
, reservedNamePage
, reservedNameEntity
, reservedNameEntityForm
-- * Data types
, reservedNameString
, reservedNameBoolean
, reservedNameBooleanTrue
, reservedNameBooleanFalse
]
waspLanguageDef :: Token.LanguageDef ()
waspLanguageDef = emptyDef
{ Token.commentLine = "//"
, Token.reservedNames = reservedNames
, Token.caseSensitive = True
-- Identifier
, Token.identStart = letter
, Token.identLetter = alphaNum <|> char '_'
}
waspLexer :: Token.TokenParser ()
waspLexer = Token.makeTokenParser waspLanguageDef
reserved :: String -> Parser ()
reserved = Token.reserved waspLexer
identifier :: Parser String
identifier = Token.identifier waspLexer
symbol :: String -> Parser String
symbol = Token.symbol waspLexer
colon :: Parser String
colon = Token.colon waspLexer
braces :: Parser a -> Parser a
braces = Token.braces waspLexer
-- Parses content between '<' and '>'.
angles :: Parser a -> Parser a
angles = Token.angles waspLexer
commaSep :: Parser a -> Parser [a]
commaSep = Token.commaSep waspLexer
commaSep1 :: Parser a -> Parser [a]
commaSep1 = Token.commaSep1 waspLexer
whiteSpace :: Parser ()
whiteSpace = Token.whiteSpace waspLexer
singleQuotes :: Parser a -> Parser a
singleQuotes p = between (symbol "'") (symbol "'") p
stringLiteral :: Parser String
stringLiteral = Token.stringLiteral waspLexer
-- * Parsing boolean values
bool :: Parser Bool
bool = boolTrue <|> boolFalse
boolTrue :: Parser Bool
boolTrue = reserved reservedNameBooleanTrue *> return True
boolFalse :: Parser Bool
boolFalse = reserved reservedNameBooleanFalse *> return False

27
waspc/src/Lib.hs Normal file
View File

@ -0,0 +1,27 @@
module Lib
( compile
) where
import CompileOptions (CompileOptions)
import qualified CompileOptions
import qualified ExternalCode
import qualified Parser
import qualified Generator
import Wasp (setExternalCodeFiles)
import qualified Path
import qualified Path.Aliases as Path
type CompileError = String
compile :: Path.AbsFile -> Path.AbsDir -> CompileOptions -> IO (Either CompileError ())
compile waspFile outDir options = do
waspStr <- readFile (Path.toFilePath waspFile)
case Parser.parseWasp waspStr of
Left err -> return $ Left (show err)
Right wasp -> do
externalCodeFiles <- ExternalCode.readFiles (CompileOptions.externalCodeDirPath options)
generateCode $ wasp `setExternalCodeFiles` externalCodeFiles
where
generateCode wasp = Generator.writeWebAppCode wasp outDir options >> return (Right ())

66
waspc/src/Parser.hs Normal file
View File

@ -0,0 +1,66 @@
module Parser
( parseWasp
) where
import Text.Parsec (ParseError, (<|>), many1, eof, many)
import Text.Parsec.String (Parser)
import qualified Wasp
import Lexer
import Parser.App (app)
import Parser.Page (page)
import Parser.Entity (entity)
import Parser.Entity.EntityForm (entityForm)
import Parser.Entity.EntityList (entityList)
import Parser.JsImport (jsImport)
import Parser.Common (runWaspParser)
waspElement :: Parser Wasp.WaspElement
waspElement
= waspElementApp
<|> waspElementPage
<|> waspElementEntity
<|> waspElementEntityForm
<|> waspElementEntityList
waspElementApp :: Parser Wasp.WaspElement
waspElementApp = Wasp.WaspElementApp <$> app
waspElementPage :: Parser Wasp.WaspElement
waspElementPage = Wasp.WaspElementPage <$> page
waspElementEntity :: Parser Wasp.WaspElement
waspElementEntity = Wasp.WaspElementEntity <$> entity
waspElementEntityForm :: Parser Wasp.WaspElement
waspElementEntityForm = Wasp.WaspElementEntityForm <$> entityForm
waspElementEntityList :: Parser Wasp.WaspElement
waspElementEntityList = Wasp.WaspElementEntityList <$> entityList
-- | Top level parser, produces Wasp.
waspParser :: Parser Wasp.Wasp
waspParser = do
-- NOTE(matija): this is the only place we need to use whiteSpace, to skip empty lines
-- and comments in the beginning of file. All other used parsers are lexeme parsers
-- so they do it themselves.
whiteSpace
jsImports <- many jsImport
waspElems <- many1 waspElement
eof
-- TODO(matija): after we parsed everything, we should do semantic analysis
-- e.g. check there is only 1 title - if not, throw a meaningful error.
-- Also, check there is at least one Page defined.
return $ (Wasp.fromWaspElems waspElems) `Wasp.setJsImports` jsImports
-- | Top level parser executor.
parseWasp :: String -> Either ParseError Wasp.Wasp
parseWasp input = runWaspParser waspParser input

42
waspc/src/Parser/App.hs Normal file
View File

@ -0,0 +1,42 @@
module Parser.App
( app
) where
import Text.Parsec
import Text.Parsec.String (Parser)
import Lexer
import qualified Wasp.App as App
import Parser.Common
-- | A type that describes supported app properties.
data AppProperty
= Title !String
| Favicon !String
deriving (Show, Eq)
-- | Parses supported app properties, expects format "key1: value1, key2: value2, ..."
appProperties :: Parser [AppProperty]
appProperties = commaSep1 $ appPropertyTitle <|> appPropertyFavicon
appPropertyTitle :: Parser AppProperty
appPropertyTitle = Title <$> waspPropertyStringLiteral "title"
appPropertyFavicon :: Parser AppProperty
-- TODO(matija): 'fav.png' currently does not work because of '.'. Support it.
appPropertyFavicon = Favicon <$> waspPropertyStringLiteral "favicon"
-- TODO(matija): unsafe, what if empty list?
getAppTitle :: [AppProperty] -> String
getAppTitle ps = head $ [t | Title t <- ps]
-- | Top level parser, parses App.
app :: Parser App.App
app = do
(appName, appProps) <- waspElementNameAndClosure reservedNameApp appProperties
return App.App
{ App.appName = appName
, App.appTitle = getAppTitle appProps
-- TODO(matija): add favicon.
}

137
waspc/src/Parser/Common.hs Normal file
View File

@ -0,0 +1,137 @@
{-
Common functions used among Wasp parsers.
-}
module Parser.Common where
import Text.Parsec (ParseError, parse, anyChar, manyTill, try, unexpected)
import Text.Parsec.String (Parser)
import qualified Data.Text as T
import qualified Path
import qualified Path.Aliases as Path
import qualified Lexer as L
-- | Runs given wasp parser on a specified input.
runWaspParser :: Parser a -> String -> Either ParseError a
runWaspParser waspParser input = parse waspParser sourceName input
where
-- NOTE(matija): this is used by Parsec only when reporting errors, but we currently
-- don't provide source name (e.g. .wasp file name) to this method so leaving it empty
-- for now.
sourceName = ""
-- TODO(matija): rename to just "waspElement"?
-- | Parses declaration of a wasp element (e.g. App or Page) and the closure content.
waspElementNameAndClosure
:: String -- ^ Type of the wasp element (e.g. "app" or "page").
-> Parser a -- ^ Parser to be used for parsing closure content of the wasp element.
-> Parser (String, a) -- ^ Name of the element and parsed closure content.
waspElementNameAndClosure elementType closure =
-- NOTE(matija): It is important to have `try` here because we don't want to consume the
-- content intended for other parsers.
-- E.g. if we tried to parse "entity-form" this parser would have been tried first for
-- "entity" and would consume "entity", so entity-form parser would also fail.
-- This way when entity parser fails, it will backtrack and allow
-- entity-form parser to succeed.
--
-- TODO(matija): should I push this try higher, to the specific case of entity parser
-- which is causing the trouble?
-- This way try will be executed in more cases where it is not neccessary, this
-- might not be the best for the performance and the clarity of error messages.
-- On the other hand, it is safer?
try $ do
-- TODO(matija): should we somehow check if this is a reserved name?
L.reserved elementType
elementName <- L.identifier
closureContent <- waspClosure closure
return (elementName, closureContent)
-- | Parses declaration of a wasp element linked to an entity.
-- E.g. "entity-form<Task> {...}" or "entity-list<Task> {...}"
waspElementLinkedToEntity
:: String -- ^ Type of the linked wasp element (e.g. "entity-form").
-> Parser a -- ^ Parser to be used for parsing closure content of the wasp element.
-> Parser (String, String, a) -- ^ Name of the linked entity, element name and closure content.
waspElementLinkedToEntity elementType closure = do
L.reserved elementType
linkedEntityName <- L.angles L.identifier
elementName <- L.identifier
closureContent <- waspClosure closure
return (linkedEntityName, elementName, closureContent)
-- | Parses wasp property along with the key, "key: value".
waspProperty :: String -> Parser a -> Parser a
waspProperty key value = L.symbol key <* L.colon *> value
-- | Parses wasp property which has a string literal for a value.
-- e.g.: title: "my first app"
waspPropertyStringLiteral :: String -> Parser String
waspPropertyStringLiteral key = waspProperty key L.stringLiteral
-- | Parses wasp property which has a bool for a value. E.g.: "onEnter: true".
waspPropertyBool :: String -> Parser Bool
waspPropertyBool key = waspProperty key L.bool
-- | Parses wasp closure, which is {...}. Returns parsed content within the closure.
waspClosure :: Parser a -> Parser a
waspClosure = L.braces
-- | Parses wasp property closure where property is an identifier whose value we also
-- need to retrieve.
-- E.g. within an entity-form {} we can set properties for a specific field with a closure of
-- form "FIELD_NAME: {...}" -> FIELD_NAME is then an identifier we need.
waspIdentifierClosure :: Parser a -> Parser (String, a)
waspIdentifierClosure closureContent = do
identifier <- L.identifier <* L.colon
content <- waspClosure closureContent
return (identifier, content)
-- | Parses wasp property which has a closure for a value. Returns parsed content within the
-- closure.
waspPropertyClosure :: String -> Parser a -> Parser a
waspPropertyClosure key closureContent = waspProperty key (waspClosure closureContent)
-- | Parses wasp property which has a jsx closure for a value. Returns the content
-- within the closure.
waspPropertyJsxClosure :: String -> Parser String
waspPropertyJsxClosure key = waspProperty key waspJsxClosure
-- | Parses wasp property which has a css closure for a value. Returns the content
-- within the closure.
waspPropertyCssClosure :: String -> Parser String
waspPropertyCssClosure key = waspProperty key waspCssClosure
-- | Parses wasp jsx closure, which is {=jsx...jsx=}. Returns content within the closure.
waspJsxClosure :: Parser String
waspJsxClosure = waspNamedClosure "jsx"
-- | Parses wasp css closure, which is {=css...css=}. Returns content within the closure.
waspCssClosure :: Parser String
waspCssClosure = waspNamedClosure "css"
-- TODO(martin): write tests and comments.
-- | Parses wasp css closure, which is {=name...name=}. Returns content within the closure.
waspNamedClosure :: String -> Parser String
waspNamedClosure name = do
_ <- closureStart
strip <$> (manyTill anyChar (try closureEnd))
where
closureStart = L.symbol ("{=" ++ name)
closureEnd = L.symbol (name ++ "=}")
-- | Removes leading and trailing spaces from a string.
strip :: String -> String
strip = T.unpack . T.strip . T.pack
-- | Parses relative file path, e.g. "my/file.txt".
relFilePathString :: Parser Path.RelFile
relFilePathString = do
path <- L.stringLiteral
maybe (unexpected $ "string \"" ++ path ++ "\": Expected relative file path.")
return
(Path.parseRelFile path)

View File

@ -0,0 +1,61 @@
module Parser.Entity
( entity
-- For testing purposes.
, entityFieldType
, entityField
, entityFields
) where
import Text.Parsec ((<|>))
import Text.Parsec.String (Parser)
import Lexer
( identifier
, colon
, commaSep1
, reserved
, reservedNameEntity
, reservedNameString
, reservedNameBoolean
)
import qualified Wasp.Entity as Entity
import qualified Parser.Common as P
-- | Top level parser, parses Entity.
entity :: Parser Entity.Entity
entity = do
(name, fields) <- P.waspElementNameAndClosure reservedNameEntity entityFields
return Entity.Entity
{ Entity.entityName = name
, Entity.entityFields = fields
}
-- Parses entity fields.
entityFields :: Parser [Entity.EntityField]
entityFields = commaSep1 entityField
-- | Parses a single entity field, e.g. "title :: string".
entityField :: Parser Entity.EntityField
entityField = do
fieldName <- identifier
_ <- typeAssignmentOp
fieldType <- entityFieldType
return $ Entity.EntityField fieldName fieldType
where
-- TODO(matija): maybe specify it under reservedOps in Lexer?
typeAssignmentOp = colon *> colon
-- | Parses one of the supported types of the entity field e.g. "string" or "boolean".
entityFieldType :: Parser Entity.EntityFieldType
entityFieldType = entityFieldTypeString <|> entityFieldTypeBoolean
-- | Parses string type of entity field.
entityFieldTypeString :: Parser Entity.EntityFieldType
entityFieldTypeString = reserved reservedNameString *> pure Entity.EftString
-- | Parses boolean type of entity field.
entityFieldTypeBoolean :: Parser Entity.EntityFieldType
entityFieldTypeBoolean = reserved reservedNameBoolean *> pure Entity.EftBoolean

View File

@ -0,0 +1,39 @@
module Parser.Entity.Common
( waspPropertyEntityFields
) where
import Text.Parsec.String (Parser)
import qualified Lexer as L
import Parser.Common as P
-- A function that takes an entity field name (e.g. "description) and a list of parsed field
-- options, and then creates a final Wasp AST record from it (fieldConfig).
type CreateFieldConfig fieldOption fieldConfig = (String, [fieldOption]) -> fieldConfig
-- | Parses configuration of fields within a wasp entity component (e.g. entity-form
-- or entity-list). Parses the following format:
--
-- fields: { FIELD_NAME: {...}, FIELD_NAME: {...}, ... }
--
-- At least one field must be specified.
waspPropertyEntityFields
:: Parser fo -- ^ Parser of a single field option.
-> CreateFieldConfig fo fc -- ^ Function that creates a record with all parsed field options.
-> Parser [fc] -- ^ Field configs, a list of record with all the field options.
waspPropertyEntityFields fieldOptionP createFieldConfig = P.waspPropertyClosure "fields" $
L.commaSep1 $ waspPropertyEntityField fieldOptionP createFieldConfig
-- | Parses configuration of a specific field within a wasp entity component (e.g. entity-form
-- or entity-list). Parses the following format:
--
-- FIELD_NAME: { option1, option2 }
--
-- At least one option must be present.
waspPropertyEntityField
:: Parser fo -- ^ Parser of a single field option.
-> CreateFieldConfig fo fc -- ^ Function that creates a record with all parsed field options.
-> Parser fc -- ^ Field config, a record with all the field options.
waspPropertyEntityField fieldOptionP createFieldConfig =
(P.waspIdentifierClosure $ L.commaSep1 fieldOptionP) >>= (return . createFieldConfig)

View File

@ -0,0 +1,166 @@
module Parser.Entity.EntityForm
( entityForm
-- For testing
, entityFormOptionSubmit
, EntityFormOption (..)
, submitConfig
) where
import Text.Parsec (choice, (<|>))
import Text.Parsec.String (Parser)
import qualified Wasp.EntityForm as WEF
import Wasp.EntityForm (EntityForm)
import qualified Parser.Common as P
import qualified Parser.Entity.Common as PE
import qualified Util as U
import qualified Lexer as L
-- * EntityForm
-- | Parses entity form, e.g. "entity-form<Task> NewTaskForm {...}"
entityForm :: Parser EntityForm
entityForm = do
(entityName, formName, options) <-
P.waspElementLinkedToEntity L.reservedNameEntityForm entityFormOptions
return WEF.EntityForm
{ WEF._name = formName
, WEF._entityName = entityName
, WEF._submit = maybeGetSubmitConfig options
, WEF._fields = getFieldsConfig options
}
data EntityFormOption
= EfoSubmit WEF.Submit
| EfoFields [WEF.Field]
deriving (Show, Eq)
entityFormOptions :: Parser [EntityFormOption]
entityFormOptions = L.commaSep entityFormOption
entityFormOption :: Parser EntityFormOption
entityFormOption = choice
[ entityFormOptionSubmit
, entityFormOptionFields
]
-- * Submit
maybeGetSubmitConfig :: [EntityFormOption] -> Maybe WEF.Submit
maybeGetSubmitConfig options = U.headSafe [s | EfoSubmit s <- options]
entityFormOptionSubmit :: Parser EntityFormOption
entityFormOptionSubmit = EfoSubmit <$> (P.waspPropertyClosure "submit" submitConfig)
submitConfig :: Parser WEF.Submit
submitConfig = do
-- TODO(matija): this pattern of "having at least 1 property in closure" could be further
-- extracted to e.g. "waspClosureOptions" - but again sometimes it is ok not to have any props,
-- e.g. EntityForm. Maybe then "waspClosureOptions1" and "waspClosureOptions"?
options <- L.commaSep1 submitOption
return WEF.Submit
{ WEF._onEnter = maybeGetSoOnEnter options
, WEF._submitButton = maybeGetSoSubmitButton options
}
data SubmitOption = SoOnEnter Bool | SoSubmitButton WEF.SubmitButton deriving (Show, Eq)
submitOption :: Parser SubmitOption
submitOption = choice [submitOptionOnEnter, submitOptionSubmitButton]
-- onEnter
submitOptionOnEnter :: Parser SubmitOption
submitOptionOnEnter = SoOnEnter <$> P.waspPropertyBool "onEnter"
maybeGetSoOnEnter :: [SubmitOption] -> Maybe Bool
maybeGetSoOnEnter options = U.headSafe [b | SoOnEnter b <- options]
-- submit button
submitOptionSubmitButton :: Parser SubmitOption
submitOptionSubmitButton = SoSubmitButton <$> P.waspPropertyClosure "button" submitButtonConfig
maybeGetSoSubmitButton :: [SubmitOption] -> Maybe WEF.SubmitButton
maybeGetSoSubmitButton options = U.headSafe [sb | SoSubmitButton sb <- options]
submitButtonConfig :: Parser WEF.SubmitButton
submitButtonConfig = do
options <- L.commaSep1 submitButtonOption
return WEF.SubmitButton
{ WEF._submitButtonShow = maybeGetSboShow options
}
data SubmitButtonOption = SboShow Bool deriving (Show, Eq)
submitButtonOption :: Parser SubmitButtonOption
submitButtonOption = submitButtonOptionShow -- <|> anotherOption <|> ...
submitButtonOptionShow :: Parser SubmitButtonOption
submitButtonOptionShow = SboShow <$> P.waspPropertyBool "show"
maybeGetSboShow :: [SubmitButtonOption] -> Maybe Bool
maybeGetSboShow options = U.headSafe [b | SboShow b <- options]
-- * Fields
getFieldsConfig :: [EntityFormOption] -> [WEF.Field]
getFieldsConfig options = case [fs | EfoFields fs <- options] of
[] -> []
ls -> head ls
entityFormOptionFields :: Parser EntityFormOption
entityFormOptionFields = EfoFields <$> PE.waspPropertyEntityFields fieldOption createFieldConfig
createFieldConfig :: (String, [FieldOption]) -> WEF.Field
createFieldConfig (fieldName, options) = WEF.Field
{ WEF._fieldName = fieldName
, WEF._fieldShow = maybeGetFieldOptionShow options
, WEF._fieldDefaultValue = maybeGetFieldOptionDefaultValue options
, WEF._fieldPlaceholder = maybeGetFieldOptionPlaceholder options
, WEF._fieldLabel = maybeGetFieldOptionLabel options
}
data FieldOption
= FieldOptionShow Bool
| FieldOptionDefaultValue WEF.DefaultValue
| FieldOptionPlaceholder String
| FieldOptionLabel (Maybe String)
deriving (Show, Eq)
-- | Parses a single field option, e.g. "show" or "defaultValue".
fieldOption :: Parser FieldOption
fieldOption = choice
[ FieldOptionShow <$> P.waspPropertyBool "show"
, FieldOptionDefaultValue <$> defaultValue
, FieldOptionPlaceholder <$> P.waspPropertyStringLiteral "placeholder"
, FieldOptionLabel <$> fieldOptionLabel
]
defaultValue :: Parser WEF.DefaultValue
defaultValue = P.waspProperty "defaultValue" $ choice
[ WEF.DefaultValueString <$> L.stringLiteral
, WEF.DefaultValueBool <$> L.bool
]
fieldOptionLabel :: Parser (Maybe String)
fieldOptionLabel = P.waspProperty "label" labelValue
where
labelValue = (Just <$> L.stringLiteral) <|> (L.symbol "none" *> pure Nothing)
maybeGetFieldOptionShow :: [FieldOption] -> Maybe Bool
maybeGetFieldOptionShow options = U.headSafe [b | FieldOptionShow b <- options]
maybeGetFieldOptionDefaultValue :: [FieldOption] -> Maybe WEF.DefaultValue
maybeGetFieldOptionDefaultValue options = U.headSafe [dv | FieldOptionDefaultValue dv <- options]
maybeGetFieldOptionPlaceholder :: [FieldOption] -> Maybe String
maybeGetFieldOptionPlaceholder options = U.headSafe [s | FieldOptionPlaceholder s <- options]
maybeGetFieldOptionLabel :: [FieldOption] -> Maybe (Maybe String)
maybeGetFieldOptionLabel options = U.headSafe [ms | FieldOptionLabel ms <- options]

View File

@ -0,0 +1,83 @@
module Parser.Entity.EntityList
( entityList
) where
import Text.Parsec (choice)
import Text.Parsec.String (Parser)
import qualified Wasp.EntityList as WEL
import Wasp.EntityList (EntityList)
import qualified Wasp.JsCode as WJS
import qualified Parser.JsCode
import qualified Parser.Common as P
import qualified Parser.Entity.Common as PE
import qualified Util as U
import qualified Lexer as L
-- * EntityList
-- | Parses entity list, e.g. "entity-list<Task> TaskList {...}"
entityList :: Parser EntityList
entityList = do
(entityName, listName, options) <-
P.waspElementLinkedToEntity L.reservedNameEntityList entityListOptions
return WEL.EntityList
{ WEL._name = listName
, WEL._entityName = entityName
, WEL._showHeader = maybeGetListOptionShowHeader options
, WEL._fields = getFieldsConfig options
}
data EntityListOption
= EloShowHeader Bool
| EloFields [WEL.Field]
deriving (Show, Eq)
entityListOptions :: Parser [EntityListOption]
-- TODO(matija): this could be further abstracted as waspClosureOptions option ->
-- that way we abstract L.commaSep
entityListOptions = L.commaSep entityListOption
entityListOption :: Parser EntityListOption
entityListOption = choice
[ EloShowHeader <$> P.waspPropertyBool "showHeader"
, entityListOptionFields
]
-- * Show header
maybeGetListOptionShowHeader :: [EntityListOption] -> Maybe Bool
maybeGetListOptionShowHeader options = U.headSafe [b | EloShowHeader b <- options]
-- * Fields
getFieldsConfig :: [EntityListOption] -> [WEL.Field]
getFieldsConfig options = case [fs | EloFields fs <- options] of
[] -> []
ls -> head ls
entityListOptionFields :: Parser EntityListOption
entityListOptionFields = EloFields <$> PE.waspPropertyEntityFields fieldOption createFieldConfig
createFieldConfig :: (String, [FieldOption]) -> WEL.Field
createFieldConfig (fieldName, options) = WEL.Field
{ WEL._fieldName = fieldName
, WEL._fieldRender = maybeGetFieldOptionRender options
}
data FieldOption
= FieldOptionRender WJS.JsCode
fieldOption :: Parser FieldOption
fieldOption = choice
[ fieldOptionRender
]
fieldOptionRender :: Parser FieldOption
fieldOptionRender = FieldOptionRender <$> P.waspProperty "render" Parser.JsCode.jsCode
maybeGetFieldOptionRender :: [FieldOption] -> Maybe WJS.JsCode
maybeGetFieldOptionRender options = U.headSafe [js | FieldOptionRender js <- options]

View File

@ -0,0 +1,21 @@
module Parser.ExternalCode
( extCodeFilePathString
) where
import Text.Parsec (unexpected)
import Text.Parsec.String (Parser)
import Path (reldir)
import qualified Path
import qualified Path.Aliases as Path
import qualified Parser.Common
-- Parses string literal that is file path to file in external code dir.
-- Returns file path relative to the external code dir.
-- Example of input: "@ext/some/file.txt". Output would be: "some/file.txt".
extCodeFilePathString :: Parser Path.RelFile
extCodeFilePathString = do
path <- Parser.Common.relFilePathString
maybe (unexpected $ "string \"" ++ (show path) ++ "\": External code file path should start with \"@ext/\".")
return
(Path.stripProperPrefix [reldir|@ext|] path)

View File

@ -0,0 +1,12 @@
module Parser.JsCode
( jsCode
) where
import Text.Parsec.String (Parser)
import qualified Data.Text as Text
import qualified Parser.Common as P
import qualified Wasp.JsCode as WJS
jsCode :: Parser WJS.JsCode
jsCode = (WJS.JsCode . Text.pack) <$> P.waspNamedClosure "js"

View File

@ -0,0 +1,25 @@
module Parser.JsImport
( jsImport
) where
import Text.Parsec (manyTill, anyChar, try)
import Text.Parsec.Char (space)
import Text.Parsec.String (Parser)
import qualified Parser.ExternalCode
import qualified Lexer as L
import qualified Wasp.JsImport
-- import ... from "..."
jsImport :: Parser Wasp.JsImport.JsImport
jsImport = do
L.whiteSpace
_ <- L.reserved L.reservedNameImport
-- TODO: In the future, we could further tighten up this parser so it strictly follows format of js import statement.
-- Right now it is more relaxed -> it allows almost anything between "import" and "from".
what <- anyChar `manyTill` (try (space *> L.whiteSpace *> L.reserved L.reservedNameFrom))
-- TODO: For now we only support double quotes here, we should also support single quotes.
-- We would need to write this from scratch, with single quote escaping enabled.
from <- Parser.ExternalCode.extCodeFilePathString
return Wasp.JsImport.JsImport { Wasp.JsImport.jsImportWhat = what, Wasp.JsImport.jsImportFrom = from }

64
waspc/src/Parser/Page.hs Normal file
View File

@ -0,0 +1,64 @@
module Parser.Page
( page
) where
import Text.Parsec
import Text.Parsec.String (Parser)
import Data.Maybe (listToMaybe)
import Lexer
import qualified Wasp.Page as Page
import qualified Wasp.Style
import Parser.Common
import qualified Parser.Style
data PageProperty
= Title !String
| Route !String
| Content !String
| Style !Wasp.Style.Style
deriving (Show, Eq)
-- | Parses Page properties, separated by a comma.
pageProperties :: Parser [PageProperty]
pageProperties = commaSep1 $
pagePropertyTitle
<|> pagePropertyRoute
<|> pagePropertyContent
<|> pagePropertyStyle
pagePropertyTitle :: Parser PageProperty
pagePropertyTitle = Title <$> waspPropertyStringLiteral "title"
pagePropertyRoute :: Parser PageProperty
pagePropertyRoute = Route <$> waspPropertyStringLiteral "route"
pagePropertyContent :: Parser PageProperty
pagePropertyContent = Content <$> waspPropertyJsxClosure "content"
pagePropertyStyle :: Parser PageProperty
pagePropertyStyle = Style <$> waspProperty "style" Parser.Style.style
-- TODO(matija): unsafe, what if empty list?
getPageRoute :: [PageProperty] -> String
-- TODO(matija): we are repeating this pattern. How can we extract it? Consider using
-- Template Haskell, lens and prism.
getPageRoute ps = head $ [r | Route r <- ps]
getPageContent :: [PageProperty] -> String
getPageContent ps = head $ [c | Content c <- ps]
getPageStyle :: [PageProperty] -> Maybe Wasp.Style.Style
getPageStyle ps = listToMaybe [s | Style s <- ps]
-- | Top level parser, parses Page.
page :: Parser Page.Page
page = do
(pageName, pageProps) <- waspElementNameAndClosure reservedNamePage pageProperties
return Page.Page
{ Page.pageName = pageName
, Page.pageRoute = getPageRoute pageProps
, Page.pageContent = getPageContent pageProps
, Page.pageStyle = getPageStyle pageProps
}

21
waspc/src/Parser/Style.hs Normal file
View File

@ -0,0 +1,21 @@
module Parser.Style
( style
) where
import Text.Parsec ((<|>))
import Text.Parsec.String (Parser)
import qualified Data.Text as Text
import qualified Parser.Common
import qualified Parser.ExternalCode
import qualified Wasp.Style
style :: Parser Wasp.Style.Style
style = cssFile <|> cssCode
cssFile :: Parser Wasp.Style.Style
cssFile = Wasp.Style.ExtCodeCssFile <$> Parser.ExternalCode.extCodeFilePathString
cssCode :: Parser Wasp.Style.Style
cssCode = (Wasp.Style.CssCode . Text.pack) <$> Parser.Common.waspNamedClosure "css"

View File

@ -0,0 +1,9 @@
module Path.Aliases where
import Path (Path, Abs, Rel, Dir, File)
type RelFile = Path Rel File
type AbsFile = Path Abs File
type RelDir = Path Rel Dir
type AbsDir = Path Abs Dir

29
waspc/src/Path/Extra.hs Normal file
View File

@ -0,0 +1,29 @@
module Path.Extra
( (./)
, reversePath
) where
import Control.Exception (assert)
import System.FilePath as FP
import Path
-- NOTE: Here we return FilePath, because Path can't keep the "./" in it,
-- since it always normalizes the path. So the only way to have path with "./" in it
-- is to have it as a FilePath.
(./) :: Path Rel a -> FP.FilePath
(./) relPath = "." FP.</> (toFilePath relPath)
-- | For given path P, returns path P', such that (terminal pseudocode incoming)
-- `pwd == (cd P && cd P' && pwd)`, or to put it differently, such that
-- `cd P && cd P'` is noop (does nothing).
-- It will always be either "." (only if input is ".") or a series of ".."
-- (e.g. reversePath [reldir|foo/bar|] == "../..").
reversePath :: Path Rel Dir -> FilePath
reversePath path
| length parts == 0 = "."
| otherwise = assert (not (".." `elem` parts)) $
FP.joinPath $ map (const "..") parts
where
parts :: [String]
parts = filter (not . (== ".")) $ FP.splitDirectories $ toFilePath path

44
waspc/src/Util.hs Normal file
View File

@ -0,0 +1,44 @@
module Util
( camelToKebabCase
, onFirst
, toLowerFirst
, toUpperFirst
, headSafe
, jsonSet
) where
import Data.Char (isUpper, toLower, toUpper)
import qualified Data.Aeson as Aeson
import qualified Data.Text as Text
import qualified Data.HashMap.Strict as M
camelToKebabCase :: String -> String
camelToKebabCase "" = ""
camelToKebabCase camel@(camelHead:camelTail) = kebabHead:kebabTail
where
kebabHead = toLower camelHead
kebabTail = concat $ map
(\(a, b) -> (if (isCamelHump (a, b)) then ['-'] else []) ++ [toLower b])
(zip camel camelTail)
isCamelHump (a, b) = (not . isUpper) a && isUpper b
-- | Applies given function to the first element of the list.
-- If list is empty, returns empty list.
onFirst :: (a -> a) -> [a] -> [a]
onFirst _ [] = []
onFirst f (x:xs) = (f x):xs
toLowerFirst :: String -> String
toLowerFirst = onFirst toLower
toUpperFirst :: String -> String
toUpperFirst = onFirst toUpper
headSafe :: [a] -> Maybe a
headSafe [] = Nothing
headSafe xs = Just (head xs)
jsonSet :: Text.Text -> Aeson.Value -> Aeson.Value -> Aeson.Value
jsonSet key value (Aeson.Object o) = Aeson.Object $ M.insert key value o
jsonSet _ _ _ = error "Input JSON must be an object"

9
waspc/src/Util/Fib.hs Normal file
View File

@ -0,0 +1,9 @@
module Util.Fib (
fibonacci
) where
fibonacci :: Int -> Int
fibonacci 0 = 0
fibonacci 1 = 1
fibonacci n | n > 1 = (fibonacci (n - 1)) + (fibonacci (n - 2))
fibonacci _ = undefined

57
waspc/src/Util/IO.hs Normal file
View File

@ -0,0 +1,57 @@
module Util.IO
( listDirectoryDeep
) where
import qualified System.Directory as Dir
import qualified System.FilePath as FilePath
import System.IO.Error (isDoesNotExistError)
import Control.Exception (catch, throw)
import Control.Monad (filterM, mapM)
import qualified Path
import qualified Path.Aliases as Path
-- TODO: write tests.
-- | Lists all files in the directory recursively.
-- All paths are relative to the directory we are listing.
-- If directory does not exist, returns empty list.
--
-- Example: Imagine we have directory foo that contains test.txt and bar/test2.txt.
-- If we call
-- >>> listDirectoryDeep "foo/"
-- we should get
-- >>> ["test.txt", "bar/text2.txt"]
listDirectoryDeep :: Path.AbsDir -> IO [Path.RelFile]
listDirectoryDeep absDirPath = do
(relFilePaths, relSubDirPaths) <- (listDirectory absDirPath)
`catch` \e -> if isDoesNotExistError e then return ([], []) else throw e
relSubDirFilesPaths <- mapM (listSubDirDeep . (absDirPath Path.</>)) relSubDirPaths
return $ relFilePaths ++ (concat relSubDirFilesPaths)
where
-- | NOTE: Here, returned paths are relative to the main dir whose sub dir we are listing,
-- which is one level above what you might intuitively expect.
listSubDirDeep :: Path.AbsDir -> IO [Path.RelFile]
listSubDirDeep subDirPath = do
files <- listDirectoryDeep subDirPath
return $ map ((Path.dirname subDirPath) Path.</>) files
-- TODO: write tests.
-- | Lists files and directories at top lvl of the directory.
listDirectory :: Path.AbsDir -> IO ([Path.RelFile], [Path.RelDir])
listDirectory absDirPath = do
fpRelItemPaths <- Dir.listDirectory fpAbsDirPath
relFilePaths <- filterFiles fpAbsDirPath fpRelItemPaths
relDirPaths <- filterDirs fpAbsDirPath fpRelItemPaths
return (relFilePaths, relDirPaths)
where
fpAbsDirPath :: FilePath
fpAbsDirPath = Path.toFilePath absDirPath
filterFiles :: FilePath -> [FilePath] -> IO [Path.RelFile]
filterFiles absDir relItems = filterM (Dir.doesFileExist . (absDir FilePath.</>)) relItems
>>= mapM Path.parseRelFile
filterDirs :: FilePath -> [FilePath] -> IO [Path.RelDir]
filterDirs absDir relItems = filterM (Dir.doesDirectoryExist . (absDir FilePath.</>)) relItems
>>= mapM Path.parseRelDir

149
waspc/src/Wasp.hs Normal file
View File

@ -0,0 +1,149 @@
module Wasp
( Wasp
, WaspElement (..)
, fromWaspElems
, module Wasp.JsImport
, getJsImports
, setJsImports
, module Wasp.App
, fromApp
, getApp
, setApp
, module Wasp.Entity
, getEntities
, addEntity
, getEntityByName
, getEntityFormsForEntity
, getEntityListsForEntity
, module Wasp.Page
, getPages
, addPage
, setExternalCodeFiles
, getExternalCodeFiles
) where
import Data.Aeson ((.=), object, ToJSON(..))
import qualified ExternalCode
import Wasp.App
import Wasp.Entity
import qualified Wasp.EntityForm as EF
import qualified Wasp.EntityList as EL
import Wasp.JsImport
import Wasp.Page
import qualified Util as U
-- * Wasp
data Wasp = Wasp
{ waspElements :: [WaspElement]
, waspJsImports :: [JsImport]
, externalCodeFiles :: [ExternalCode.File]
} deriving (Show, Eq)
data WaspElement
= WaspElementApp !App
| WaspElementPage !Page
| WaspElementEntity !Entity
| WaspElementEntityForm !EF.EntityForm
| WaspElementEntityList !EL.EntityList
deriving (Show, Eq)
fromWaspElems :: [WaspElement] -> Wasp
fromWaspElems elems = Wasp
{ waspElements = elems
, waspJsImports = []
, externalCodeFiles = []
}
-- * External code files
getExternalCodeFiles :: Wasp -> [ExternalCode.File]
getExternalCodeFiles = externalCodeFiles
setExternalCodeFiles :: Wasp -> [ExternalCode.File] -> Wasp
setExternalCodeFiles wasp files = wasp { externalCodeFiles = files }
-- * Js imports
getJsImports :: Wasp -> [JsImport]
getJsImports = waspJsImports
setJsImports :: Wasp -> [JsImport] -> Wasp
setJsImports wasp jsImports = wasp { waspJsImports = jsImports }
-- * App
getApp :: Wasp -> App
getApp wasp = let apps = getApps wasp in
if (length apps /= 1)
then error "Wasp has to contain exactly one WaspElementApp element!"
else head apps
isAppElem :: WaspElement -> Bool
isAppElem WaspElementApp{} = True
isAppElem _ = False
getApps :: Wasp -> [App]
getApps wasp = [app | (WaspElementApp app) <- waspElements wasp]
setApp :: Wasp -> App -> Wasp
setApp wasp app = wasp { waspElements = (WaspElementApp app) : (filter (not . isAppElem) (waspElements wasp)) }
fromApp :: App -> Wasp
fromApp app = fromWaspElems [WaspElementApp app]
-- * Pages
getPages :: Wasp -> [Page]
getPages wasp = [page | (WaspElementPage page) <- waspElements wasp]
addPage :: Wasp -> Page -> Wasp
addPage wasp page = wasp { waspElements = (WaspElementPage page):(waspElements wasp) }
-- * Entities
getEntities :: Wasp -> [Entity]
getEntities wasp = [entity | (WaspElementEntity entity) <- (waspElements wasp)]
-- | Gets entity with a specified name from wasp, if such an entity exists.
-- Entity name must be unique, so there can be no more than one such entity.
getEntityByName :: Wasp -> String -> Maybe Entity
getEntityByName wasp name = U.headSafe $ filter (\e -> entityName e == name) (getEntities wasp)
addEntity :: Wasp -> Entity -> Wasp
addEntity wasp entity = wasp { waspElements = (WaspElementEntity entity):(waspElements wasp) }
-- * EntityForm
-- | Retrieves all entity forms for a given entity from a Wasp record.
getEntityFormsForEntity :: Wasp -> Entity -> [EF.EntityForm]
getEntityFormsForEntity wasp entity = filter isFormOfGivenEntity allEntityForms
where
allEntityForms = [entityForm | (WaspElementEntityForm entityForm) <- waspElements wasp]
isFormOfGivenEntity ef = entityName entity == EF._entityName ef
-- * EntityList
-- | Retrieves all entity lists for a given entity from a Wasp record.
getEntityListsForEntity :: Wasp -> Entity -> [EL.EntityList]
getEntityListsForEntity wasp entity = filter isListOfGivenEntity allEntityLists
where
allEntityLists = [entityList | (WaspElementEntityList entityList) <- waspElements wasp]
isListOfGivenEntity el = entityName entity == EL._entityName el
-- * ToJSON instances.
instance ToJSON Wasp where
toJSON wasp = object
[ "app" .= getApp wasp
, "pages" .= getPages wasp
, "jsImports" .= getJsImports wasp
]

17
waspc/src/Wasp/App.hs Normal file
View File

@ -0,0 +1,17 @@
module Wasp.App
( App(..)
) where
import Data.Aeson ((.=), object, ToJSON(..))
data App = App
{ appName :: !String -- Identifier
, appTitle :: !String
} deriving (Show, Eq)
instance ToJSON App where
toJSON app = object
[ "name" .= appName app
, "title" .= appTitle app
]

41
waspc/src/Wasp/Entity.hs Normal file
View File

@ -0,0 +1,41 @@
module Wasp.Entity
( Entity (..)
, EntityField (..)
, EntityFieldType (..)
) where
import qualified Data.Text as Text
import qualified Data.Aeson as Aeson
import Data.Aeson ((.=), object, ToJSON(..))
data Entity = Entity
{ entityName :: !String
, entityFields :: ![EntityField]
} deriving (Show, Eq)
data EntityField = EntityField
{ entityFieldName :: !String
, entityFieldType :: !EntityFieldType
} deriving (Show, Eq)
data EntityFieldType = EftString | EftBoolean deriving (Eq)
instance Show EntityFieldType where
show EftString = "string"
show EftBoolean = "boolean"
instance ToJSON Entity where
toJSON entity = object
[ "name" .= entityName entity
, "fields" .= entityFields entity
]
instance ToJSON EntityField where
toJSON entityField = object
[ "name" .= entityFieldName entityField
, "type" .= entityFieldType entityField
]
instance ToJSON EntityFieldType where
toJSON = Aeson.String . Text.pack . show

View File

@ -0,0 +1,93 @@
module Wasp.EntityForm
( EntityForm(..)
, Submit(..)
, SubmitButton(..)
, Field(..)
, DefaultValue(..)
, getConfigForField
) where
import Data.Aeson ((.=), object, ToJSON(..))
import qualified Util as U
import qualified Wasp.Entity as Entity
data EntityForm = EntityForm
{ _name :: !String -- Name of the form
, _entityName :: !String -- Name of the entity the form is linked to
-- TODO(matija): should we make these maybes also strict?
, _submit :: Maybe Submit
, _fields :: [Field]
} deriving (Show, Eq)
-- NOTE(matija): Ideally generator would not depend on this logic defined outside of it.
-- We are moving away from this approach but some parts of code (Page generator) still
-- rely on it so we cannot remove it completely yet without further refactoring.
--
-- Some record fields are note even included (e.g. _fields), we are keeping this only for the
-- backwards compatibility.
instance ToJSON EntityForm where
toJSON entityForm = object
[ "name" .= _name entityForm
, "entityName" .= _entityName entityForm
, "submitConfig" .= _submit entityForm
]
-- | For a given entity field, returns its configuration from the given entity-form, if present.
getConfigForField :: EntityForm -> Entity.EntityField -> Maybe Field
getConfigForField entityForm entityField =
U.headSafe $ filter isConfigOfInputEntityField $ _fields entityForm
where
isConfigOfInputEntityField :: Field -> Bool
isConfigOfInputEntityField =
(== Entity.entityFieldName entityField) . _fieldName
-- * Submit
data Submit = Submit
{ _onEnter :: Maybe Bool
, _submitButton :: Maybe SubmitButton
} deriving (Show, Eq)
data SubmitButton = SubmitButton
{ _submitButtonShow :: Maybe Bool
} deriving (Show, Eq)
instance ToJSON Submit where
toJSON submit = object
[ "onEnter" .= _onEnter submit
]
-- * Field
data Field = Field
{ _fieldName :: !String
, _fieldShow :: Maybe Bool
, _fieldDefaultValue :: Maybe DefaultValue
, _fieldPlaceholder :: Maybe String
-- NOTE(matija): We use Maybe (Maybe String) here to differentiate between the 3
-- possible states:
--
-- Nothing -> property not provided (by user)
-- Just Nothing -> property was provided and explicitly set to Nothing
-- Just (Just val) -> property was provided and explicitly set to some value.
--
-- We introduced this because we need to differentiate between the case when user did not
-- provide a property (we want to display a label with a default value) and a case when user
-- explicitly disabled the label (we want not to display the label at all).
--
-- This is an experiment, we are not sure if this will prove to be practical. With introducing
-- this new type of "none", the question is where else it can be applied etc.
--
-- Alternative solution would be to introduce another property, e.g. "showLabel: true|false"
-- then we would have avoided the need to introduce this new "type".
, _fieldLabel :: Maybe (Maybe String)
} deriving (Show, Eq)
data DefaultValue
= DefaultValueString String
| DefaultValueBool Bool
deriving (Show, Eq)

View File

@ -0,0 +1,50 @@
module Wasp.EntityList
( EntityList(..)
, Field(..)
, getConfigForField
) where
import Data.Aeson ((.=), object, ToJSON(..))
import Wasp.JsCode (JsCode)
import qualified Util as U
import qualified Wasp.Entity as Entity
data EntityList = EntityList
{ _name :: !String -- Name of the list
, _entityName :: !String -- Name of the entity the form is linked to
, _showHeader :: Maybe Bool -- Should the list header be displayed or not
, _fields :: [Field]
} deriving (Show, Eq)
-- NOTE(matija): Ideally generator would not depend on this logic defined outside of it.
-- We are moving away from this approach but some parts of code (Page generator) still
-- rely on it so we cannot remove it completely yet without further refactoring.
--
-- Some record fields are note even included (e.g. _fields), we are keeping this only for the
-- backwards compatibility.
instance ToJSON EntityList where
toJSON entityList = object
[ "name" .= _name entityList
, "entityName" .= _entityName entityList
]
-- | For a given entity field, returns its configuration from the given entity-list, if present.
-- TODO(matija): this is very similar to the same function in EntityForm, we could extract it
-- (prob. using typeclass or TH) in the future.
getConfigForField :: EntityList -> Entity.EntityField -> Maybe Field
getConfigForField entityList entityField =
U.headSafe $ filter isConfigOfInputEntityField $ _fields entityList
where
isConfigOfInputEntityField :: Field -> Bool
isConfigOfInputEntityField =
(== Entity.entityFieldName entityField) . _fieldName
-- * Field
data Field = Field
{ _fieldName :: !String
, _fieldRender :: Maybe JsCode -- Js function that renders a list field.
} deriving (Show, Eq)

14
waspc/src/Wasp/JsCode.hs Normal file
View File

@ -0,0 +1,14 @@
module Wasp.JsCode
( JsCode(..)
) where
import Data.Aeson (ToJSON(..))
import Data.Text (Text)
data JsCode = JsCode !Text deriving (Show, Eq)
-- TODO(matija): Currently generator is relying on this implementation, which is not
-- ideal. Ideally all the generation logic would be in the generator. But for now this was
-- the simplest way to implement it.
instance ToJSON JsCode where
toJSON (JsCode code) = toJSON code

View File

@ -0,0 +1,23 @@
module Wasp.JsImport
( JsImport(..)
) where
import Data.Aeson ((.=), object, ToJSON(..))
import qualified Path.Aliases as Path
-- | Represents javascript import -> "import <what> from <from>".
data JsImport = JsImport
{ jsImportWhat :: !String
-- | Path of file to import, relative to external code directory.
-- So for example if jsImportFrom is "test.js", we expect file
-- to exist at <external_code_dir>/test.js.
-- TODO: Make this more explicit in the code (both here and in wasp lang)? Also, support importing npm packages?
, jsImportFrom :: !Path.RelFile
} deriving (Show, Eq)
instance ToJSON JsImport where
toJSON jsImport = object
[ "what" .= jsImportWhat jsImport
, "from" .= jsImportFrom jsImport
]

24
waspc/src/Wasp/Page.hs Normal file
View File

@ -0,0 +1,24 @@
module Wasp.Page
( Page(..)
) where
import Data.Aeson ((.=), object, ToJSON(..))
import qualified Wasp.Style as WStyle
data Page = Page
{ pageName :: !String
, pageRoute :: !String
, pageContent :: !String
-- | TODO(martin): I did not know how to apply strictness annotation (!) here.
, pageStyle :: Maybe WStyle.Style
} deriving (Show, Eq)
instance ToJSON Page where
toJSON page = object
[ "name" .= pageName page
, "route" .= pageRoute page
, "content" .= pageContent page
, "style" .= pageStyle page
]

16
waspc/src/Wasp/Style.hs Normal file
View File

@ -0,0 +1,16 @@
module Wasp.Style
( Style(..)
) where
import Data.Aeson (ToJSON(..))
import Data.Text (Text)
import qualified Path.Aliases as Path
data Style = ExtCodeCssFile !Path.RelFile
| CssCode !Text
deriving (Show, Eq)
instance ToJSON Style where
toJSON (ExtCodeCssFile path) = toJSON path
toJSON (CssCode code) = toJSON code

65
waspc/stack.yaml Normal file
View File

@ -0,0 +1,65 @@
# This file was automatically generated by 'stack init'
#
# Some commonly used options have been documented as comments in this file.
# For advanced use and comprehensive documentation of the format, please see:
# https://docs.haskellstack.org/en/stable/yaml_configuration/
# Resolver to choose a 'specific' stackage snapshot or a compiler version.
# A snapshot resolver dictates the compiler version and the set of packages
# to be used for project dependencies. For example:
#
# resolver: lts-3.5
# resolver: nightly-2015-09-21
# resolver: ghc-7.10.2
# resolver: ghcjs-0.1.0_ghc-7.10.2
#
# The location of a snapshot can be provided as a file or url. Stack assumes
# a snapshot provided as a file might change, whereas a url resource does not.
#
# resolver: ./custom-snapshot.yaml
# resolver: https://example.com/snapshots/2018-01-01.yaml
resolver: lts-13.7
# User packages to be built.
# Various formats can be used as shown in the example below.
#
# packages:
# - some-directory
# - https://example.com/foo/bar/baz-0.0.2.tar.gz
# - location:
# git: https://github.com/commercialhaskell/stack.git
# commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a
# - location: https://github.com/commercialhaskell/stack/commit/e7b331f14bcffb8367cd58fbfc8b40ec7642100a
# subdirs:
# - auto-update
# - wai
packages:
- .
# Dependency packages to be pulled from upstream that are not in the resolver
# using the same syntax as the packages field.
# (e.g., acme-missiles-0.3)
# extra-deps: []
# Override default flag values for local packages and extra-deps
# flags: {}
# Extra package databases containing global packages
# extra-package-dbs: []
# Control whether we use the GHC we find on the path
# system-ghc: true
#
# Require a specific version of stack, using version ranges
# require-stack-version: -any # Default
# require-stack-version: ">=1.7"
#
# Override the architecture used by stack, especially useful on Windows
# arch: i386
# arch: x86_64
#
# Extra directories used by stack for building
# extra-include-dirs: [/path/to/dir]
# extra-lib-dirs: [/path/to/dir]
#
# Allow a newer minor version of GHC than the snapshot specifies
# compiler-check: newer-minor

65
waspc/test/Fixtures.hs Normal file
View File

@ -0,0 +1,65 @@
module Fixtures where
import Wasp
import qualified Wasp.EntityForm as EF
import qualified Generator.Entity.EntityForm as GEF
app :: App
app = App
{ appName = "test_app"
, appTitle = "Hello World!"
}
taskEntity :: Entity
taskEntity = Entity
{ entityName = "Task"
, entityFields =
[ Wasp.EntityField "description" Wasp.EftString
, Wasp.EntityField "isDone" Wasp.EftBoolean
]
}
taskCreateForm :: EF.EntityForm
taskCreateForm = EF.EntityForm
{ EF._name = "CreateTaskForm"
, EF._entityName = "Task"
, EF._submit = Just EF.Submit
{ EF._onEnter = Just False
, EF._submitButton = Nothing
}
, EF._fields = []
}
userEntity :: Entity
userEntity = Entity
{ entityName = "User"
, entityFields =
[ Wasp.EntityField "name" Wasp.EftString
, Wasp.EntityField "surname" Wasp.EftString
]
}
userCreateForm :: EF.EntityForm
userCreateForm = EF.EntityForm
{ EF._name = "CreateUserForm"
, EF._entityName = "User"
, EF._submit = Nothing
, EF._fields = []
}
wasp :: Wasp
wasp = fromWaspElems
[ WaspElementApp app
, WaspElementEntity taskEntity
, WaspElementEntityForm taskCreateForm
]
formFieldIsDone :: GEF.FormFieldTemplateData
formFieldIsDone = GEF.FormFieldTemplateData
{ GEF._fieldName = "isDone"
, GEF._fieldType = Wasp.EftBoolean
, GEF._fieldShow = True
, GEF._fieldDefaultValue = EF.DefaultValueBool True
, GEF._fieldPlaceholder = Nothing
, GEF._fieldLabel = Nothing
}

View File

@ -0,0 +1,40 @@
module Generator.Entity.EntityFormTest where
import Test.Tasty.Hspec
import Path ((</>))
import Wasp
import Generator.FileDraft
import qualified Generator.FileDraft.TemplateFileDraft as TmplFD
import Generator.Entity.EntityForm
import qualified Generator.Common as Common
import qualified Fixtures as F
spec_generateEntityCreateForm :: Spec
spec_generateEntityCreateForm = do
let waspWithTask = fromWaspElems
[ WaspElementApp F.app
, WaspElementEntity F.taskEntity
, WaspElementEntityForm F.taskCreateForm
]
{-
-- NOTE(matija): Could not get this to work - the problem was that FileDraft has to be the
-- instance of NFData typeclass so it can be fully evaulated, I did not want to go further
-- into that.
it "When given entity form for which there is no entity in wasp, throws an error." $ do
(evaluate . force) (generateEntityCreateForm waspWithTask F.userCreateForm)
`shouldThrow`
anyErrorCall
-}
it "When given entity form for which there is an entity in wasp, \
\returns correct file draft." $ do
let (FileDraftTemplateFd templateFileDraft) =
generateEntityCreateForm waspWithTask F.taskCreateForm
let expectedDstPath = Common.srcDirPath </> (entityCreateFormPathInSrc F.taskEntity F.taskCreateForm)
TmplFD._dstPath templateFileDraft `shouldBe` expectedDstPath

View File

@ -0,0 +1,40 @@
module Generator.EntityTest where
import Test.Tasty.Hspec
import Path ((</>), relfile)
import Wasp
import Generator.FileDraft
import Generator.FileDraft.TemplateFileDraft
import Generator.Entity
spec_EntityGenerator :: Spec
spec_EntityGenerator = do
let testApp = (App "TestApp" "Test App")
let testEntity = (Entity "TestEntity" [EntityField "testField" EftString])
let testWasp = (fromApp testApp) `addEntity` testEntity
describe "generateEntityClass" $ do
testGeneratorUsesCorrectSrcPath
testWasp generateEntityClass (entityTemplatesDirPath </> [relfile|_Entity.js|])
describe "generateEntityActions" $ do
testGeneratorUsesCorrectSrcPath
testWasp generateEntityActions (entityTemplatesDirPath </> [relfile|actions.js|])
describe "generateEntityActionTypes" $ do
testGeneratorUsesCorrectSrcPath
testWasp generateEntityActionTypes (entityTemplatesDirPath </> [relfile|actionTypes.js|])
describe "generateEntityState" $ do
testGeneratorUsesCorrectSrcPath
testWasp generateEntityState (entityTemplatesDirPath </> [relfile|state.js|])
where
testGeneratorUsesCorrectSrcPath testWasp generator expectedSrcPath = it
"Given a simple Wasp, creates template file draft with correct src path" $ do
let (FileDraftTemplateFd (TemplateFileDraft _ srcPath _))
= generator testWasp (head $ getEntities testWasp)
srcPath `shouldBe` expectedSrcPath

View File

@ -0,0 +1,19 @@
module Generator.ExternalCode.JsTest where
import Test.Tasty.Hspec
import Path (relfile)
import Generator.ExternalCode.Js
spec_resolveJsFileWaspImports :: Spec
spec_resolveJsFileWaspImports = do
([relfile|ext-src/extFile.js|], "import foo from 'bar'") ~> "import foo from 'bar'"
([relfile|ext-src/extFile.js|], "import foo from '@wasp/bar'") ~> "import foo from '../bar'"
([relfile|ext-src/a/extFile.js|], "import foo from \"@wasp/bar/foo\"") ~>
"import foo from \"../../bar/foo\""
where
(path, text) ~> expectedText =
it ((show path) ++ " " ++ (show text) ++ " -> " ++ (show expectedText)) $ do
resolveJsFileWaspImports path text `shouldBe` expectedText

View File

@ -0,0 +1,27 @@
module Generator.FileDraft.CopyFileDraftTest where
import Test.Tasty.Hspec
import qualified Path
import Path ((</>), absdir, relfile, absfile)
import Generator.FileDraft
import qualified Generator.MockWriteableMonad as Mock
spec_CopyFileDraft :: Spec
spec_CopyFileDraft = do
describe "write" $ do
it "Creates new file by copying existing file" $ do
let mock = write dstDir fileDraft
let mockLogs = Mock.getMockLogs mock Mock.defaultMockConfig
Mock.createDirectoryIfMissing_calls mockLogs
`shouldBe` [(True, Path.toFilePath $ Path.parent expectedDstPath)]
Mock.copyFile_calls mockLogs
`shouldBe` [(Path.toFilePath expectedSrcPath, Path.toFilePath expectedDstPath)]
where
(dstDir, dstPath, srcPath) = ([absdir|/a/b|], [relfile|c/d/dst.txt|], [absfile|/e/src.txt|])
fileDraft = createCopyFileDraft dstPath srcPath
expectedSrcPath = srcPath
expectedDstPath = dstDir </> dstPath

View File

@ -0,0 +1,37 @@
module Generator.FileDraft.TemplateFileDraftTest where
import Test.Tasty.Hspec
import Data.Aeson (object, (.=))
import Data.Text (Text)
import qualified Path
import Path ((</>), absdir, relfile)
import Generator.FileDraft
import qualified Generator.MockWriteableMonad as Mock
spec_TemplateFileDraft :: Spec
spec_TemplateFileDraft = do
describe "write" $ do
it "Creates new file from existing template file" $ do
let mock = write dstDir fileDraft
let mockLogs = Mock.getMockLogs mock mockConfig
Mock.compileAndRenderTemplate_calls mockLogs
`shouldBe` [(templatePath, templateData)]
Mock.createDirectoryIfMissing_calls mockLogs
`shouldBe` [(True, Path.toFilePath $ Path.parent expectedDstPath)]
Mock.writeFileFromText_calls mockLogs
`shouldBe` [(Path.toFilePath expectedDstPath, mockTemplateContent)]
where
(dstDir, dstPath, templatePath) = ([absdir|/a/b|], [relfile|c/d/dst.txt|], [relfile|e/tmpl.txt|])
templateData = object [ "foo" .= ("bar" :: String) ]
fileDraft = createTemplateFileDraft dstPath templatePath (Just templateData)
expectedDstPath = dstDir </> dstPath
mockTemplatesDirAbsPath = [absdir|/mock/templates/dir|]
mockTemplateContent = "Mock template content" :: Text
mockConfig = Mock.defaultMockConfig
{ Mock.getTemplatesDirAbsPath_impl = mockTemplatesDirAbsPath
, Mock.compileAndRenderTemplate_impl = \_ _ -> mockTemplateContent
}

View File

@ -0,0 +1,83 @@
module Generator.GeneratorsTest where
import Test.Tasty.Hspec
import System.FilePath ((</>), (<.>))
import Path (absdir)
import qualified Path
import Util
import qualified CompileOptions
import Generator.Generators
import Generator.FileDraft
import qualified Generator.FileDraft.TemplateFileDraft as TmplFD
import qualified Generator.FileDraft.CopyFileDraft as CopyFD
import qualified Generator.FileDraft.TextFileDraft as TextFD
import qualified Generator.Common as Common
import Wasp
-- TODO(martin): We could define Arbitrary instance for Wasp, define properties over
-- generator functions and then do property testing on them, that would be cool.
spec_Generators :: Spec
spec_Generators = do
let testApp = (App "TestApp" "Test App")
let testPage = (Page "TestPage" "/test-page" "<div>Test Page</div>" Nothing)
let testEntity = (Entity "TestEntity" [EntityField "testField" EftString])
let testWasp = (fromApp testApp) `addPage` testPage `addEntity` testEntity
let testCompileOptions = CompileOptions.CompileOptions
{ CompileOptions.externalCodeDirPath = [absdir|/test/src|]
}
describe "generateWebApp" $ do
-- NOTE: This test does not (for now) check that content of files is correct or
-- that they will successfully be written, it checks only that their
-- destinations are correct.
it "Given a simple Wasp, creates file drafts at expected destinations" $ do
let fileDrafts = generateWebApp testWasp testCompileOptions
let testEntityDstDirInSrc
= "entities" </> (Util.camelToKebabCase (entityName testEntity))
let expectedFileDraftDstPaths = concat $
[ [ "README.md"
, "package.json"
, ".gitignore"
]
, map ("public" </>)
[ "favicon.ico"
, "index.html"
, "manifest.json"
]
, map ((Path.toFilePath Common.srcDirPath) </>)
[ "logo.png"
, "index.css"
, "index.js"
, "reducers.js"
, "router.js"
, "serviceWorker.js"
, (pageName testPage <.> "js")
, "store/index.js"
, "store/middleware/logger.js"
, testEntityDstDirInSrc </> "actions.js"
, testEntityDstDirInSrc </> "actionTypes.js"
, testEntityDstDirInSrc </> "state.js"
]
]
mapM_
-- NOTE(martin): I added fd to the pair here in order to have it
-- printed when shouldBe fails, otherwise I could not know which
-- file draft failed.
(\dstPath -> (dstPath, existsFdWithDst fileDrafts dstPath)
`shouldBe` (dstPath, True))
expectedFileDraftDstPaths
existsFdWithDst :: [FileDraft] -> FilePath -> Bool
existsFdWithDst fds dstPath = any ((== dstPath) . getFileDraftDstPath) fds
-- TODO(martin): This should really become part of the Writeable typeclass,
-- since it is smth we want to do for all file drafts.
getFileDraftDstPath :: FileDraft -> FilePath
getFileDraftDstPath (FileDraftTemplateFd fd) = Path.toFilePath $ TmplFD._dstPath fd
getFileDraftDstPath (FileDraftCopyFd fd) = Path.toFilePath $ CopyFD._dstPath fd
getFileDraftDstPath (FileDraftTextFd fd) = Path.toFilePath $ TextFD._dstPath fd

View File

@ -0,0 +1,108 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Generator.MockWriteableMonad
( MockWriteableMonad
, MockWriteableMonadLogs(..)
, MockWriteableMonadConfig(..)
, getMockLogs
, defaultMockConfig
) where
import Data.Text (Text, pack)
import Control.Monad.State
import qualified Data.Aeson as Aeson
import Path ((</>), absdir)
import qualified Path.Aliases as Path
import Generator.FileDraft.WriteableMonad
-- TODO: Instead of manually defining mock like this, consider using monad-mock package,
-- it should do most of this automatically, now there is a lot of boilerplate.
-- Or we ourselves can maybe use template haskell to reduce duplication.
defaultMockConfig :: MockWriteableMonadConfig
defaultMockConfig = MockWriteableMonadConfig
{ getTemplatesDirAbsPath_impl = [absdir|/mock/templates/dir|]
, getTemplateFileAbsPath_impl = \path -> [absdir|/mock/templates/dir|] </> path
, compileAndRenderTemplate_impl = \_ _ -> (pack "Mock template content")
}
getMockLogs :: MockWriteableMonad a -> MockWriteableMonadConfig -> MockWriteableMonadLogs
getMockLogs mock config = fst $ execState (unMockWriteableMonad mock) (emptyLogs, config)
where
emptyLogs = MockWriteableMonadLogs [] [] [] [] [] []
instance WriteableMonad MockWriteableMonad where
writeFileFromText dstPath text = MockWriteableMonad $ do
modifyLogs (writeFileFromText_addCall dstPath text)
getTemplatesDirAbsPath = MockWriteableMonad $ do
modifyLogs getTemplatesDirAbsPath_addCall
(_, config) <- get
return $ getTemplatesDirAbsPath_impl config
createDirectoryIfMissing createParents path = MockWriteableMonad $ do
modifyLogs (createDirectoryIfMissing_addCall createParents path)
copyFile srcPath dstPath = MockWriteableMonad $ do
modifyLogs (copyFile_addCall srcPath dstPath)
getTemplateFileAbsPath path = MockWriteableMonad $ do
modifyLogs (getTemplateFileAbsPath_addCall path)
(_, config) <- get
return $ (getTemplateFileAbsPath_impl config) path
compileAndRenderTemplate path json = MockWriteableMonad $ do
modifyLogs (compileAndRenderTemplate_addCall path json)
(_, config) <- get
return $ (compileAndRenderTemplate_impl config) path json
modifyLogs :: MonadState (a, b) m => (a -> a) -> m ()
modifyLogs f = modify (\(logs, config) -> (f logs, config))
newtype MockWriteableMonad a = MockWriteableMonad
{ unMockWriteableMonad :: State (MockWriteableMonadLogs, MockWriteableMonadConfig) a
}
deriving (Monad, Applicative, Functor)
data MockWriteableMonadLogs = MockWriteableMonadLogs
{ writeFileFromText_calls :: [(FilePath, Text)]
, getTemplatesDirAbsPath_calls :: [()]
, createDirectoryIfMissing_calls :: [(Bool, FilePath)]
, copyFile_calls :: [(FilePath, FilePath)]
, getTemplateFileAbsPath_calls :: [(Path.RelFile)]
, compileAndRenderTemplate_calls :: [(Path.RelFile, Aeson.Value)]
}
data MockWriteableMonadConfig = MockWriteableMonadConfig
{ getTemplatesDirAbsPath_impl :: Path.AbsDir
, getTemplateFileAbsPath_impl :: Path.RelFile -> Path.AbsFile
, compileAndRenderTemplate_impl :: Path.RelFile -> Aeson.Value -> Text
}
writeFileFromText_addCall :: FilePath -> Text -> MockWriteableMonadLogs -> MockWriteableMonadLogs
writeFileFromText_addCall path text logs =
logs { writeFileFromText_calls = (path, text):(writeFileFromText_calls logs) }
getTemplatesDirAbsPath_addCall :: MockWriteableMonadLogs -> MockWriteableMonadLogs
getTemplatesDirAbsPath_addCall logs =
logs { getTemplatesDirAbsPath_calls = ():(getTemplatesDirAbsPath_calls logs) }
getTemplateFileAbsPath_addCall :: Path.RelFile -> MockWriteableMonadLogs -> MockWriteableMonadLogs
getTemplateFileAbsPath_addCall path logs =
logs { getTemplateFileAbsPath_calls = (path):(getTemplateFileAbsPath_calls logs) }
copyFile_addCall :: FilePath -> FilePath -> MockWriteableMonadLogs -> MockWriteableMonadLogs
copyFile_addCall srcPath dstPath logs =
logs { copyFile_calls = (srcPath, dstPath):(copyFile_calls logs) }
createDirectoryIfMissing_addCall :: Bool -> FilePath -> MockWriteableMonadLogs -> MockWriteableMonadLogs
createDirectoryIfMissing_addCall createParents path logs =
logs { createDirectoryIfMissing_calls =
(createParents, path):(createDirectoryIfMissing_calls logs) }
compileAndRenderTemplate_addCall :: Path.RelFile -> Aeson.Value -> MockWriteableMonadLogs -> MockWriteableMonadLogs
compileAndRenderTemplate_addCall path json logs =
logs { compileAndRenderTemplate_calls =
(path, json):(compileAndRenderTemplate_calls logs) }

View File

@ -0,0 +1,23 @@
module Generator.PageGeneratorTest where
import Test.Tasty.Hspec
import Path (relfile)
import Wasp
import Generator.FileDraft
import Generator.FileDraft.TemplateFileDraft
import Generator.PageGenerator
spec_PageGenerator :: Spec
spec_PageGenerator = do
let testApp = (App "TestApp" "Test App")
let testPage = (Page "TestPage" "/test-page" "<div>Test Page</div>" Nothing)
let testWasp = (fromApp testApp) `addPage` testPage
describe "generatePageComponent" $ do
it "Given a simple Wasp, creates template file draft from _Page.js" $ do
let (FileDraftTemplateFd (TemplateFileDraft _ srcPath _))
= generatePageComponent testWasp (head $ getPages testWasp)
srcPath `shouldBe` [relfile|src/_Page.js|]

View File

@ -0,0 +1,100 @@
module Parser.CommonTest where
import Test.Tasty.Hspec
import Text.Parsec
import Data.Either
import Path (relfile)
import Lexer
import Parser.Common
spec_parseWaspCommon :: Spec
spec_parseWaspCommon = do
describe "Parsing wasp element linked to an entity" $ do
it "When given a valid declaration, parses it correctly." $ do
runWaspParser (waspElementLinkedToEntity "entity-form" whiteSpace) "entity-form<Task> TaskForm { }"
`shouldBe` Right ("Task", "TaskForm", ())
describe "Parsing wasp element name and properties" $ do
let parseWaspElementNameAndClosure elemKeyword p input =
runWaspParser (waspElementNameAndClosure elemKeyword p) input
it "When given valid wasp element declaration along with whitespace parser,\
\ returns an expected result" $ do
parseWaspElementNameAndClosure "app" whiteSpace "app someApp { }"
`shouldBe` Right ("someApp", ())
it "When given valid wasp element declaration along with char parser, returns\
\ an expected result" $ do
parseWaspElementNameAndClosure "app" (char 'a') "app someApp {a}"
`shouldBe` Right ("someApp", 'a')
it "When given wasp element declaration with invalid name, returns Left" $ do
(isLeft $ parseWaspElementNameAndClosure "app" whiteSpace "app 1someApp { }")
`shouldBe` True
describe "Parsing wasp closure" $ do
it "Parses a closure with braces {}" $ do
runWaspParser (waspClosure (symbol "content")) "{ content }"
`shouldBe` Right "content"
it "Does not parse a closure with brackets []" $ do
(isLeft $ runWaspParser (waspClosure (symbol "content")) "[ content ]")
`shouldBe` True
describe "Parsing wasp property with a closure as a value" $ do
it "When given a string as a key and closure as a value, returns closure content." $ do
runWaspParser (waspPropertyClosure "someKey" (symbol "content")) "someKey: { content }"
`shouldBe` Right "content"
describe "Parsing wasp property - string literal" $ do
let parseWaspPropertyStringLiteral key input =
runWaspParser (waspPropertyStringLiteral key) input
it "When given key/value with int value, returns Left." $ do
isLeft (parseWaspPropertyStringLiteral "title" "title: 23")
`shouldBe` True
it "When given key/value with string value, returns a parsed value." $ do
let appTitle = "my first app"
parseWaspPropertyStringLiteral "title" ("title: \"" ++ appTitle ++ "\"")
`shouldBe` Right appTitle
describe "Parsing wasp property - jsx closure {=jsx...jsx=}" $ do
let parseWaspPropertyJsxClosure key input =
runWaspParser (waspPropertyJsxClosure key) input
it "When given unexpected property key, returns Left." $ do
isLeft (parseWaspPropertyJsxClosure "content" "title: 23")
`shouldBe` True
it "When given content within jsx closure, returns that content." $ do
parseWaspPropertyJsxClosure "content" "content: {=jsx some content jsx=}"
`shouldBe` Right "some content"
describe "Parsing wasp jsx closure" $ do
let parseWaspJsxClosure input = runWaspParser waspJsxClosure input
let closureContent = "<div>hello world</div>"
it "Returns the content of closure" $ do
parseWaspJsxClosure ("{=jsx " ++ closureContent ++ " jsx=}")
`shouldBe` Right closureContent
it "Can parse braces {} within the closure" $ do
let closureContentWithBraces = "<div>hello world {task.length}</div>"
parseWaspJsxClosure ("{=jsx " ++ closureContentWithBraces ++ " jsx=}")
`shouldBe` Right closureContentWithBraces
it "Removes leading and trailing spaces" $ do
parseWaspJsxClosure ("{=jsx " ++ closureContent ++ " jsx=}")
`shouldBe` Right closureContent
describe "Parsing relative file path string" $ do
it "Correctly parses relative path in double quotes" $ do
runWaspParser relFilePathString "\"foo/bar.txt\""
`shouldBe` Right [relfile|foo/bar.txt|]
it "When path is not relative, returns Left" $ do
isLeft (runWaspParser relFilePathString "\"/foo/bar.txt\"") `shouldBe` True

View File

@ -0,0 +1,55 @@
module Parser.EntityFormTest where
import Test.Tasty.Hspec
import Parser.Common (runWaspParser)
import Parser.Entity.EntityForm
( entityForm
, submitConfig
, entityFormOptionSubmit
, EntityFormOption (..)
)
import qualified Wasp.EntityForm as EF
spec_parseEntityForm :: Spec
spec_parseEntityForm = do
describe "Parsing Wasp element entity-form" $ do
it "When given an entity form with submit config, it is included in the result." $ do
runWaspParser entityForm "entity-form<Task> someEntityForm { submit: {onEnter: true} }"
`shouldBe` Right EF.EntityForm
{ EF._name = "someEntityForm"
, EF._entityName = "Task"
, EF._submit = Just (EF.Submit
{ EF._onEnter = Just True
, EF._submitButton = Nothing
}
)
, EF._fields = []
}
it "When given an entity form without submit config, it is not included in the result." $ do
runWaspParser entityForm "entity-form<Task> someEntityForm {}"
`shouldBe` Right EF.EntityForm
{ EF._name = "someEntityForm"
, EF._entityName = "Task"
, EF._submit = Nothing
, EF._fields = []
}
describe "Parsing submit option - submit: {...}" $ do
it "When given a valid submit option declaration, parses it correctly." $ do
runWaspParser entityFormOptionSubmit "submit: { onEnter: true }"
`shouldBe` Right (EfoSubmit EF.Submit
{ EF._onEnter = Just True
, EF._submitButton = Nothing
})
describe "Parsing submit config - closure content" $ do
it "When given a valid submit configuration, parses it correctly." $ do
runWaspParser submitConfig "onEnter: true"
`shouldBe` Right EF.Submit
{ EF._onEnter = Just True
, EF._submitButton = Nothing
}

View File

@ -0,0 +1,58 @@
module Parser.EntityTest where
import Test.Tasty.Hspec
import Data.Either (isLeft)
import Parser.Common (runWaspParser)
import Parser.Entity
( entityField
, entityFields
, entity
, entityFieldType
)
import qualified Wasp
spec_parseEntity :: Spec
spec_parseEntity = do
let titleStringField = Wasp.EntityField "title" Wasp.EftString
let isDoneBoolField = Wasp.EntityField "isDone" Wasp.EftBoolean
describe "Parsing entity" $ do
it "When given a valid entity declaration, parses it correctly" $ do
runWaspParser entity "entity Task { title :: string, isDone :: boolean }"
`shouldBe` Right Wasp.Entity
{ Wasp.entityName = "Task"
, Wasp.entityFields = [titleStringField, isDoneBoolField]
}
describe "Parsing entity fields separated with a comma" $ do
it "When given multiple comma-separated fields, parses them correctly." $ do
runWaspParser entityFields "title :: string, isDone :: boolean"
`shouldBe` Right
[ titleStringField
, isDoneBoolField
]
describe "Parsing entity field declaration" $ do
it "When given a valid field declaration, parses it correctly" $ do
runWaspParser entityField "title :: string"
`shouldBe` Right (Wasp.EntityField "title" Wasp.EftString)
it "When given an invalid field declaration, returns Left" $ do
isLeft (runWaspParser entityField "title <>")
`shouldBe` True
describe "Parsing entity field type declaration" $ do
it "When given a field type of string, parses it correctly" $ do
runWaspParser entityFieldType "string"
`shouldBe` Right Wasp.EftString
it "When given a field type of boolean, parses it correctly" $ do
runWaspParser entityFieldType "boolean"
`shouldBe` Right Wasp.EftBoolean
it "When given an unknown field type, returns Left" $ do
isLeft (runWaspParser entityFieldType "unknownType")
`shouldBe` True

View File

@ -0,0 +1,20 @@
module Parser.ExternalCodeTest where
import Test.Tasty.Hspec
import Data.Either (isLeft)
import Path (relfile)
import Parser.ExternalCode (extCodeFilePathString)
import Parser.Common (runWaspParser)
spec_ParserExternalCode :: Spec
spec_ParserExternalCode = do
describe "Parsing external code file path string" $ do
it "Correctly parses external code path in double quotes" $ do
runWaspParser extCodeFilePathString "\"@ext/foo/bar.txt\""
`shouldBe` Right [relfile|foo/bar.txt|]
it "When path does not start with @ext/, returns Left" $ do
isLeft (runWaspParser extCodeFilePathString "\"@ext2/foo/bar.txt\"") `shouldBe` True

View File

@ -0,0 +1,37 @@
module Parser.JsImportTest where
import Test.Tasty.Hspec
import Data.Either (isLeft)
import Path (relfile)
import Parser.Common (runWaspParser)
import Parser.JsImport (jsImport)
import qualified Wasp
spec_parseJsImport :: Spec
spec_parseJsImport = do
it "Parses external code js import correctly" $ do
runWaspParser jsImport "import something from \"@ext/some/file.js\""
`shouldBe` Right (Wasp.JsImport "something" [relfile|some/file.js|])
it "Parses correctly when there is whitespace up front" $ do
runWaspParser jsImport " import something from \"@ext/some/file.js\""
`shouldBe` Right (Wasp.JsImport "something" [relfile|some/file.js|])
it "Parses correctly when 'from' is part of WHAT part" $ do
runWaspParser jsImport "import somethingfrom from \"@ext/some/file.js\""
`shouldBe` Right (Wasp.JsImport "somethingfrom" [relfile|some/file.js|])
it "Throws error if there is no whitespace after import" $ do
isLeft (runWaspParser jsImport "importsomething from \"@ext/some/file.js\"")
`shouldBe` True
it "Throws error if import is not from external code" $ do
isLeft (runWaspParser jsImport "import something from \"some/file.js\"")
`shouldBe` True
it "For now we don't support single quotes in FROM part (TODO: support them in the future!)" $ do
isLeft (runWaspParser jsImport "import something from '@ext/some/file.js'")
`shouldBe` True

View File

@ -0,0 +1,33 @@
module Parser.PageTest where
import Test.Tasty.Hspec
import Data.Either (isLeft)
import Parser.Common (runWaspParser)
import Parser.Page (page)
import qualified Wasp
spec_parsePage :: Spec
spec_parsePage =
describe "Parsing page wasp" $ do
let parsePage input = runWaspParser page input
it "When given valid page wasp declaration, returns correct Wasp.Page" $ do
let testPageName = "Landing"
let testPageRoute = "/someRoute"
let testPageContent = "<span/>"
parsePage (
"page " ++ testPageName ++ " { " ++
"route: \"" ++ testPageRoute ++ "\"," ++
"content: {=jsx " ++ testPageContent ++ " jsx=}" ++
"}")
`shouldBe` Right (Wasp.Page
{ Wasp.pageName = testPageName
, Wasp.pageRoute = testPageRoute
, Wasp.pageContent = testPageContent
, Wasp.pageStyle = Nothing
})
it "When given page wasp declaration without 'page', should return Left" $ do
isLeft (parsePage "Landing { route: someRoute }") `shouldBe` True

View File

@ -0,0 +1,106 @@
module Parser.ParserTest where
import Test.Tasty.Hspec
import Data.Either
import Path (relfile)
import Parser
import Wasp
import qualified Wasp.EntityForm as EF
import qualified Wasp.EntityList as EL
import qualified Wasp.Style
import qualified Wasp.JsCode
spec_parseWasp :: Spec
spec_parseWasp =
describe "Parsing wasp" $ do
it "When given wasp without app, should return Left" $ do
isLeft (parseWasp "hoho") `shouldBe` True
before (readFile "test/Parser/valid.wasp") $ do
it "When given a valid wasp source, should return correct\
\ Wasp" $ \wasp -> do
parseWasp wasp
`shouldBe`
Right (fromWaspElems
[ WaspElementApp $ App
{ appName = "test_app"
, appTitle = "Hello World!"
}
, WaspElementPage $ Page
{ pageName = "Landing"
, pageRoute = "/"
-- TODO: This is heavily hardcoded and hard to maintain, we should find
-- better way to test this (test a property, not exact text?) Or keep valid.wasp simple?
-- Or use manual snapshot file as Matija suggested?
, pageContent = "<div>\n\
\ My landing page! I have { this.props.taskList.length } tasks.\n\
\\n\
\ <div>\n\
\ <TaskCreateForm\n\
\ onCreate={task => this.props.addTask(task)}\n\
\ submitButtonLabel={'Create new task'}\n\
\ />\n\
\ </div>\n\
\\n\
\ My tasks\n\
\ <TaskList />\n\
\ </div>"
, pageStyle = Just $ Wasp.Style.CssCode "div {\n\
\ color: red\n\
\ }"
}
, WaspElementPage $ Page
{ pageName = "TestPage"
, pageRoute = "/test"
, pageContent = "<div>This is a test page!</div>"
, pageStyle = Just $ Wasp.Style.ExtCodeCssFile [relfile|test.css|]
}
, WaspElementEntity $ Entity
{ entityName = "Task"
, entityFields =
[ Wasp.EntityField "description" Wasp.EftString
, Wasp.EntityField "isDone" Wasp.EftBoolean
]
}
, WaspElementEntityForm $ EF.EntityForm
{ EF._name = "CreateTaskForm"
, EF._entityName = "Task"
, EF._submit = Just EF.Submit
{ EF._onEnter = Just False
, EF._submitButton = Just EF.SubmitButton
{ EF._submitButtonShow = Just True
}
}
, EF._fields =
[ EF.Field
{ EF._fieldName = "description"
, EF._fieldShow = Just True
, EF._fieldDefaultValue = Just $ EF.DefaultValueString "doable task"
, EF._fieldLabel = Just Nothing
, EF._fieldPlaceholder = Just "What will you do?"
}
, EF.Field
{ EF._fieldName = "isDone"
, EF._fieldShow = Just False
, EF._fieldDefaultValue = Just $ EF.DefaultValueBool False
, EF._fieldLabel = Nothing
, EF._fieldPlaceholder = Nothing
}
]
}
, WaspElementEntityList $ EL.EntityList
{ EL._name = "TaskList"
, EL._entityName = "Task"
, EL._showHeader = Just False
, EL._fields =
[ EL.Field
{ EL._fieldName = "description"
, EL._fieldRender = Just $ Wasp.JsCode.JsCode "task => task.description"
}
]
}
]
`setJsImports` [ JsImport "something" [relfile|some/file|] ]
)

View File

@ -0,0 +1,25 @@
module Parser.StyleTest where
import Test.Tasty.Hspec
import Data.Either (isLeft)
import Path (relfile)
import Parser.Common (runWaspParser)
import Parser.Style (style)
import qualified Wasp.Style
spec_parseStyle :: Spec
spec_parseStyle = do
it "Parses external code file path correctly" $ do
runWaspParser style "\"@ext/some/file.js\""
`shouldBe` Right (Wasp.Style.ExtCodeCssFile [relfile|some/file.js|])
it "Parses css closure correctly" $ do
runWaspParser style "{=css Some css code css=}"
`shouldBe` Right (Wasp.Style.CssCode "Some css code")
it "Throws error if path is not external code path." $ do
isLeft (runWaspParser style "\"some/file.js\"")
`shouldBe` True

Some files were not shown because too many files have changed in this diff Show More