├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .tidyrc.json ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── packages.dhall ├── spago.dhall └── src ├── Main.js ├── Main.purs └── Pscid ├── Console.js ├── Console.purs ├── Error.purs ├── Keypress.js ├── Keypress.purs ├── Options.js ├── Options.purs ├── Process.purs ├── Psa.purs ├── Server.purs └── Util.purs /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x, 20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, build, and test 21 | run: | 22 | npm ci 23 | npm run build 24 | npm run format:check 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /output/ 3 | /.psci* 4 | /.psa-stash 5 | .DS_Store 6 | /.psc-ide-port 7 | /.spago* 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.github/ 2 | /.spago/ 3 | /.psci* 4 | /output/*/externs.cbor 5 | /output/cache-db.json 6 | *.dhall 7 | /.psc-ide-port 8 | -------------------------------------------------------------------------------- /.tidyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "importSort": "ide", 3 | "importWrap": "source", 4 | "indent": 2, 5 | "operatorsFile": null, 6 | "ribbon": 1, 7 | "typeArrowPlacement": "first", 8 | "unicode": "never", 9 | "width": null 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI Status](https://github.com/kritzcreek/pscid/workflows/CI/badge.svg)](https://github.com/kritzcreek/pscid/actions) 2 | [![npm version](https://badge.fury.io/js/pscid.svg)](https://badge.fury.io/js/pscid) 3 | 4 | pscid 5 | === 6 | 7 | An editor agnostic minimal IDE for your shell. Think `spago -w build` on steroids. 8 | 9 | ### Installation 10 | 11 | `npm i -g pscid` 12 | 13 | ### Usage 14 | 15 | Start `pscid` in a terminal in the root folder of your project. 16 | 17 | pscid will show you errors and warnings (one at a time) whenever you save a PureScript source file. This makes for a nice iterative workflow. 18 | 19 | Type `b` inside `pscid`'s terminal window to build your project. This looks up the `pscid:build` script inside your package.json, then falls back to the `build` script, and then finally tries `spago build`. 20 | 21 | Type `t` inside `pscid`'s terminal window to test your project. As with building this looks up the `pscid:test` script first, then `test`, then falls back to `spago test` as a last resort. 22 | 23 | Type `q` to quit pscid. 24 | 25 | ### Suggestions 26 | 27 | Some warnings carry a suggestion from the compiler (for example redundant 28 | imports). `pscid` will prompt you to press `s` inside the terminal window when 29 | it encounters such a warning, and automatically apply the suggestion for you. 30 | 31 | ### Demo 32 | 33 | ![Demo GIF](http://i.imgur.com/ssBtu6w.gif) 34 | 35 | ### Options 36 | - `-p` The port to use. Defaults to 4243 37 | - `--include -I ` Additional directories for PureScript source files, separated by `;` 38 | - `--censor-codes ` Warning codes to ignore, seperated by `,` (just like in purescript-psa) 39 | - `--test` Runs your tests after every successful rebuild 40 | - `-O/--output` Specifies what output directory to use to load the externs for the server 41 | 42 | ### Attribution 43 | 44 | pscid utilizes https://github.com/natefaubion/purescript-psa to format and enrich the errors and warnings emitted by the compiler. 45 | 46 | It's inspired by https://github.com/ndmitchell/ghcid and https://github.com/anttih/psc-pane. 47 | 48 | ### LICENSE 49 | 50 | Copyright 2023 Christoph Hegemann and Contributors 51 | 52 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 53 | 54 | See the LICENSE file for further details. 55 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { main } from './output/Main/index.js' 3 | main() 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pscid", 3 | "type": "module", 4 | "version": "2.11.0", 5 | "description": "A lightweight editor experience for PureScript development", 6 | "repository": "https://github.com/kritzcreek/pscid", 7 | "bin": { 8 | "pscid": "index.js" 9 | }, 10 | "directories": { 11 | "test": "test" 12 | }, 13 | "scripts": { 14 | "compile": "spago build", 15 | "prepack": "rimraf output && rimraf dist && npm run compile", 16 | "build": "npm run -s compile", 17 | "start": "node index.js", 18 | "format": "purs-tidy format-in-place 'src/**/*.purs'", 19 | "format:check": "purs-tidy check 'src/**/*.purs'" 20 | }, 21 | "keywords": [ 22 | "IDE", 23 | "purescript" 24 | ], 25 | "author": "kritzcreek", 26 | "license": "LGPL-3.0", 27 | "dependencies": { 28 | "gaze": "^1.1.3", 29 | "glob": "^10.3.10", 30 | "keypress": "^0.2.1", 31 | "which": "^4.0.0" 32 | }, 33 | "devDependencies": { 34 | "purescript": "^0.15.10", 35 | "purs-tidy": "^0.10.0", 36 | "rimraf": "^5.0.5", 37 | "spago": "^0.21.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages.dhall: -------------------------------------------------------------------------------- 1 | let upstream = 2 | https://github.com/purescript/package-sets/releases/download/psc-0.15.8-20230528/packages.dhall sha256:7f1ebc6968ebabae915640456faf98f52f5606189f48cb22305fbe008723f26c 3 | 4 | in upstream 5 | with suggest = 6 | { dependencies = 7 | [ "argonaut-codecs" 8 | , "argonaut-core" 9 | , "arrays" 10 | , "bifunctors" 11 | , "console" 12 | , "effect" 13 | , "either" 14 | , "foldable-traversable" 15 | , "lists" 16 | , "maybe" 17 | , "node-buffer" 18 | , "node-fs" 19 | , "node-process" 20 | , "node-streams" 21 | , "ordered-collections" 22 | , "prelude" 23 | , "psa-utils" 24 | , "refs" 25 | , "strings" 26 | ] 27 | , repo = "https://github.com/nwolverson/purescript-suggest.git" 28 | , version = "c866dd7408902313c45bb579715f479f7f268162" 29 | } 30 | -------------------------------------------------------------------------------- /spago.dhall: -------------------------------------------------------------------------------- 1 | {- 2 | Welcome to a Spago project! 3 | You can edit this file as you like. 4 | -} 5 | { name = "pscid" 6 | , dependencies = 7 | [ "aff" 8 | , "ansi" 9 | , "argonaut" 10 | , "arrays" 11 | , "bifunctors" 12 | , "console" 13 | , "control" 14 | , "effect" 15 | , "either" 16 | , "exceptions" 17 | , "foldable-traversable" 18 | , "maybe" 19 | , "node-buffer" 20 | , "node-child-process" 21 | , "node-fs" 22 | , "node-process" 23 | , "node-streams" 24 | , "optparse" 25 | , "ordered-collections" 26 | , "partial" 27 | , "prelude" 28 | , "psa-utils" 29 | , "psc-ide" 30 | , "refs" 31 | , "strings" 32 | , "suggest" 33 | , "transformers" 34 | , "typelevel-prelude" 35 | ] 36 | , packages = ./packages.dhall 37 | , sources = [ "src/**/*.purs" ] 38 | } 39 | -------------------------------------------------------------------------------- /src/Main.js: -------------------------------------------------------------------------------- 1 | //module Main 2 | 3 | import gaze_ from 'gaze'; 4 | 5 | export function gaze(globs, cb) { 6 | gaze_(globs, { follow: true }, function (err, watcher) { 7 | // Files have all started watching 8 | // watcher === this 9 | 10 | // Get all watched files 11 | var watched = watcher.watched(); 12 | 13 | watcher.on('changed', function (filepath) { 14 | cb(filepath)(); 15 | }); 16 | 17 | watcher.on('added', function (filepath) { 18 | cb(filepath)(); 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/Main.purs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import Prelude 4 | 5 | import Control.Monad.Reader (class MonadAsk, ReaderT, ask, runReaderT) 6 | import Data.Argonaut (Json) 7 | import Data.Array as Array 8 | import Data.Either (Either(..), either, isRight) 9 | import Data.Maybe (Maybe(..)) 10 | import Data.String (Pattern(..)) 11 | import Data.String as String 12 | import Effect (Effect) 13 | import Effect.Aff (Milliseconds(..), attempt, delay, launchAff_, runAff) 14 | import Effect.Class (class MonadEffect, liftEffect) 15 | import Effect.Class.Console as Console 16 | import Effect.Ref (Ref) 17 | import Effect.Ref as Ref 18 | import Effect.Uncurried (EffectFn2, runEffectFn2) 19 | import Node.Process as Process 20 | import Psa (PsaError) 21 | import PscIde (cwd, load, sendCommandR) 22 | import PscIde.Command (Command(..), Message(..)) 23 | import Pscid.Console (clearConsole, owl, startScreen, suggestionHint) 24 | import Pscid.Error (catchLog, noSourceDirectoryError) 25 | import Pscid.Keypress (Key(..), initializeKeypresses, onKeypress) 26 | import Pscid.Options (PscidSettings, optionParser, printCLICommand) 27 | import Pscid.Process (execCommand) 28 | import Pscid.Psa (filterWarnings, parseErrors, psaPrinter) 29 | import Pscid.Server (restartServer, startServer', stopServer') 30 | import Pscid.Util (both) 31 | import Suggest (applySuggestions) 32 | import Type.Prelude as TE 33 | 34 | newtype Pscid a = Pscid (ReaderT (PscidSettings Int) Effect a) 35 | 36 | derive newtype instance functorPscid :: Functor Pscid 37 | derive newtype instance applyPscid :: Apply Pscid 38 | derive newtype instance applicativePscid :: Applicative Pscid 39 | derive newtype instance bindPscid :: Bind Pscid 40 | derive newtype instance monadPscid :: Monad Pscid 41 | instance monadAskPscid :: TE.TypeEquals r (PscidSettings Int) => MonadAsk r Pscid where 42 | ask = Pscid (map TE.from ask) 43 | 44 | instance monadEffectPscid :: MonadEffect Pscid where 45 | liftEffect = Pscid <<< liftEffect 46 | 47 | runPscid :: forall a. Pscid a -> PscidSettings Int -> Effect a 48 | runPscid (Pscid f) e = runReaderT f e 49 | 50 | newtype State = State { errors :: Array PsaError } 51 | 52 | emptyState :: State 53 | emptyState = State { errors: [] } 54 | 55 | main :: Effect Unit 56 | main = launchAff_ do 57 | config@{ port, outputDirectory, sourceDirectories } <- liftEffect optionParser 58 | when (Array.null sourceDirectories) (liftEffect noSourceDirectoryError) 59 | stateRef <- liftEffect (Ref.new emptyState) 60 | Console.log "Starting purs ide server" 61 | r <- attempt (startServer' port outputDirectory) 62 | case r of 63 | Right (Right port') -> do 64 | let config' = config { port = port' } 65 | Message directory <- do 66 | delay (Milliseconds 500.0) 67 | _ <- load port' [] [] 68 | res <- cwd port' 69 | case res of 70 | Right d -> pure d 71 | Left err -> liftEffect do 72 | Console.log err 73 | Process.exit 1 74 | liftEffect do 75 | runEffectFn2 gaze 76 | (Array.concatMap fileGlob sourceDirectories) 77 | (\d -> runPscid (triggerRebuild stateRef d) config') 78 | clearConsole 79 | initializeKeypresses 80 | onKeypress (\k -> runPscid (keyHandler stateRef k) config') 81 | Console.log ("Watching " <> directory <> " on port " <> show port') 82 | startScreen 83 | Right (Left errMsg) -> 84 | Console.log ("Failed to start psc-ide-server with: " <> errMsg) 85 | Left err -> 86 | Console.log ("Failed to start psc-ide-server with : " <> show err) 87 | 88 | -- | Given a directory, appends the globs necessary to match all PureScript and 89 | -- | JavaScript source files inside that directory 90 | fileGlob :: String -> Array String 91 | fileGlob dir = 92 | let 93 | go x = dir <> "/**/*" <> x 94 | in 95 | go <$> [ ".purs", ".js" ] 96 | 97 | keyHandler :: Ref State -> Key -> Pscid Unit 98 | keyHandler stateRef k = do 99 | { port, buildCommand, outputDirectory, testCommand } <- ask 100 | case k of 101 | Key { ctrl: false, name: "b", meta: false } -> 102 | liftEffect (execCommand "Build" $ printCLICommand buildCommand) 103 | Key { ctrl: false, name: "t", meta: false } -> 104 | liftEffect (execCommand "Test" $ printCLICommand testCommand) 105 | Key { ctrl: false, name: "r", meta: false } -> liftEffect do 106 | clearConsole 107 | catchLog "Failed to restart server" $ launchAff_ do 108 | restartServer port outputDirectory 109 | _ <- load port [] [] 110 | pure unit 111 | Console.log owl 112 | Key { ctrl: false, name: "s", meta: false } -> liftEffect do 113 | State state <- Ref.read stateRef 114 | case Array.head state.errors of 115 | Nothing -> 116 | Console.log "No suggestions available" 117 | Just e -> 118 | catchLog "Couldn't apply suggestion." (applySuggestions [ e ]) 119 | Key { ctrl: false, name: "q", meta: false } -> 120 | liftEffect (Console.log "Bye!" <* runAff (either exit exit) (stopServer' port)) 121 | Key { ctrl: true, name: "c", meta: false } -> 122 | Console.log "Press q to exit" 123 | Key { name } -> 124 | Console.log name 125 | where 126 | exit :: forall a. a -> Effect Unit 127 | exit = const (Process.exit 0) 128 | 129 | triggerRebuild :: Ref State -> String -> Pscid Unit 130 | triggerRebuild stateRef file = do 131 | { port, testCommand, testAfterRebuild, censorCodes } <- ask 132 | let fileName = changeExtension file "purs" 133 | liftEffect <<< catchLog "We couldn't talk to the server" $ launchAff_ do 134 | result <- sendCommandR port (RebuildCmd fileName Nothing Nothing) 135 | case result of 136 | Left _ -> Console.log "We couldn't talk to the server" 137 | Right errs -> liftEffect do 138 | parsedErrors <- handleRebuildResult fileName censorCodes errs 139 | Ref.write (State { errors: parsedErrors }) stateRef 140 | case Array.head parsedErrors >>= _.suggestion of 141 | Nothing -> pure unit 142 | Just _ -> suggestionHint 143 | when (testAfterRebuild && isRight errs) 144 | (execCommand "Test" $ printCLICommand testCommand) 145 | 146 | changeExtension :: String -> String -> String 147 | changeExtension s ex = 148 | case String.lastIndexOf (Pattern ".") s of 149 | Nothing -> 150 | s 151 | Just ix -> 152 | String.take ix s <> "." <> ex 153 | 154 | handleRebuildResult 155 | :: String 156 | -> Array String 157 | -> Either Json Json 158 | -> Effect (Array PsaError) 159 | handleRebuildResult file censorCodes result = do 160 | clearConsole 161 | Console.log ("Checking " <> file) 162 | case both parseErrors result of 163 | Right warnings -> 164 | either 165 | (\_ -> Console.log "Failed to parse warnings" $> []) 166 | (\e -> psaPrinter owl false e $> e) 167 | (filterWarnings censorCodes <$> warnings) 168 | Left errors -> 169 | either 170 | (\_ -> Console.log "Failed to parse errors" $> []) 171 | (\e -> psaPrinter owl true e $> e) 172 | errors 173 | 174 | foreign import gaze :: EffectFn2 (Array String) (String -> Effect Unit) Unit 175 | -------------------------------------------------------------------------------- /src/Pscid/Console.js: -------------------------------------------------------------------------------- 1 | export function clearConsole() { 2 | process.stdout.write('\x1Bc'); 3 | } 4 | -------------------------------------------------------------------------------- /src/Pscid/Console.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Console where 2 | 3 | import Prelude 4 | 5 | import Ansi.Codes (Color(..)) 6 | import Ansi.Output (foreground, withGraphics) 7 | import Effect (Effect) 8 | import Effect.Console as Console 9 | 10 | logColored :: Color -> String -> Effect Unit 11 | logColored c s = Console.log (withGraphics (foreground c) s) 12 | 13 | owl :: String 14 | owl = 15 | """ 16 | ___ ,_, ___ ,_, ___ 17 | (o,o) (o,o) ,,,(o,o),,, (o,o) (o,o) 18 | {`"'} {`"'} ';:`-':;' {`"'} {`"'} 19 | -"-"- -"-"- -"-"- -"-"- 20 | """ 21 | 22 | helpText :: String 23 | helpText = 24 | """ 25 | Press b to run a full build (tries "npm run pscid:build" then "npm run build" then "spago build") 26 | Press t to test (tries "npm run pscid:test" then "npm run test" then "spago test") 27 | Press r to reset 28 | Press q to quit 29 | """ 30 | 31 | startScreen :: Effect Unit 32 | startScreen = do 33 | Console.log owl 34 | logColored Blue helpText 35 | 36 | suggestionHint :: Effect Unit 37 | suggestionHint = 38 | logColored Blue "Press s to automatically apply the suggestion." 39 | 40 | foreign import clearConsole :: Effect Unit 41 | -------------------------------------------------------------------------------- /src/Pscid/Error.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Error where 2 | 3 | import Prelude 4 | 5 | import Ansi.Codes (Color(..)) 6 | import Effect (Effect) 7 | import Effect.Console as Console 8 | import Effect.Exception (catchException) 9 | import Node.Process as Process 10 | import Pscid.Console (logColored) 11 | 12 | catchLog :: String -> Effect Unit -> Effect Unit 13 | catchLog m = catchException (const (Console.error m)) 14 | 15 | noSourceDirectoryError :: Effect Unit 16 | noSourceDirectoryError = do 17 | logColored Red "ERROR:" 18 | Console.log helpString 19 | Process.exit 1 20 | where 21 | helpString = 22 | """I couldn't find any source directories to watch when trying app/, src/, test/ and tests/. 23 | You can specify your own semicolon separated list of folders to check with the -I option like so: 24 | 25 | $ pscid -I "sources;tests" """ 26 | -------------------------------------------------------------------------------- /src/Pscid/Keypress.js: -------------------------------------------------------------------------------- 1 | // module Pscid.Keypress 2 | 3 | import keypress from 'keypress'; 4 | 5 | export function initializeKeypresses() { 6 | keypress(process.stdin); 7 | process.stdin.setRawMode(true); 8 | process.stdin.resume(); 9 | } 10 | 11 | export function onKeypress(cb) { 12 | return function () { 13 | process.stdin.on('keypress', function (ch, key) { 14 | if (key) { 15 | cb(key)(); 16 | } 17 | }); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/Pscid/Keypress.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Keypress where 2 | 3 | import Prelude 4 | 5 | import Effect (Effect) 6 | 7 | newtype Key = Key 8 | { ctrl :: Boolean 9 | , name :: String 10 | , meta :: Boolean 11 | , shift :: Boolean 12 | } 13 | 14 | foreign import initializeKeypresses :: Effect Unit 15 | foreign import onKeypress :: (Key -> Effect Unit) -> Effect Unit 16 | -------------------------------------------------------------------------------- /src/Pscid/Options.js: -------------------------------------------------------------------------------- 1 | //module Pscid.Options 2 | 3 | import { createRequire } from 'module'; 4 | const require = createRequire(import.meta.url); 5 | 6 | export function hasNamedScript(name) { 7 | return function () { 8 | try { 9 | var pjson = require(process.cwd() + '/package.json'); 10 | return pjson.scripts && pjson.scripts[name]; 11 | } catch (e) { 12 | return false; 13 | } 14 | }; 15 | } 16 | 17 | export function glob(pattern) { 18 | return function () { 19 | return require('glob').sync(pattern); 20 | }; 21 | } 22 | 23 | export function version() { 24 | // This one references pscid's package.json 25 | var pjson = require('../../package.json'); 26 | if (!pjson) { 27 | return 'Unknown'; 28 | } else { 29 | return pjson.version; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Pscid/Options.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Options where 2 | 3 | import Prelude 4 | 5 | import Control.Alt ((<|>)) 6 | import Control.MonadPlus (guard) 7 | import Data.Array as Array 8 | import Data.Maybe (Maybe(..), fromMaybe, optional) 9 | import Data.String as String 10 | import Effect (Effect) 11 | import Effect.Console as Console 12 | import Node.Platform (Platform(..)) 13 | import Node.Process (platform) 14 | import Node.Process as Process 15 | import Options.Applicative as OA 16 | 17 | type PscidSettings a = 18 | { port :: a 19 | , buildCommand :: CLICommand 20 | , outputDirectory :: String 21 | , testCommand :: CLICommand 22 | , testAfterRebuild :: Boolean 23 | , sourceDirectories :: Array String 24 | , censorCodes :: Array String 25 | } 26 | 27 | type PscidOptions = PscidSettings (Maybe Int) 28 | 29 | defaultOptions :: PscidOptions 30 | defaultOptions = 31 | { port: Nothing 32 | , buildCommand: BuildCommand (spagoCmd <> " build") [] 33 | , outputDirectory: "output" 34 | , testCommand: BuildCommand (spagoCmd <> " test") [] 35 | , testAfterRebuild: false 36 | , sourceDirectories: [] 37 | , censorCodes: [] 38 | } 39 | 40 | -- | Scans the default directories and returns those, that did contain 41 | -- | PureScript files. 42 | scanDefaultDirectories :: Effect (Array String) 43 | scanDefaultDirectories = 44 | let 45 | defaultDirectories = [ "src", "app", "test", "tests" ] 46 | mkGlob dir = dir <> "/**/*.purs" 47 | in 48 | Array.filterA (map (not <<< Array.null) <<< glob <<< mkGlob) defaultDirectories 49 | 50 | spagoCmd :: String 51 | spagoCmd = if platform == Just Win32 then "spago.cmd" else "spago" 52 | 53 | npmCmd :: String 54 | npmCmd = if platform == Just Win32 then "npm.cmd" else "npm" 55 | 56 | mkDefaultOptions :: Effect PscidOptions 57 | mkDefaultOptions = 58 | (defaultOptions { buildCommand = _, testCommand = _, sourceDirectories = _ }) 59 | <$> mkCommand "build" 60 | <*> mkCommand "test" 61 | <*> scanDefaultDirectories 62 | 63 | type IncludePath = String 64 | 65 | data CLICommand 66 | = ScriptCommand String 67 | | BuildCommand String (Array IncludePath) 68 | 69 | printCLICommand :: CLICommand -> String 70 | printCLICommand = case _ of 71 | ScriptCommand str -> 72 | str 73 | BuildCommand str [] -> 74 | str 75 | BuildCommand str includes -> 76 | str <> " -I " <> String.joinWith ":" includes 77 | 78 | -- | If the command is a BuildCommand (eg. "spago build"), then the array 79 | -- | of include paths is set. If the command is an NPM script, the command is 80 | -- | left unchanged. This is because it's impossible to guarantee that the NPM 81 | -- | script directly executes "spago build" (it may execute another 82 | -- | script), and therefore we cannot simply append the includes onto the end of 83 | -- | the command. 84 | setCommandIncludes :: Array IncludePath -> CLICommand -> CLICommand 85 | setCommandIncludes includesArr cmd = case cmd of 86 | BuildCommand str _ -> BuildCommand str includesArr 87 | ScriptCommand str -> ScriptCommand str 88 | 89 | mkCommand :: String -> Effect CLICommand 90 | mkCommand cmd = do 91 | pscidSpecific <- hasNamedScript ("pscid:" <> cmd) 92 | namedScript <- hasNamedScript cmd 93 | 94 | let 95 | npmSpecificCommand = 96 | guard pscidSpecific $> ScriptCommand (npmCmd <> " run -s pscid:" <> cmd) 97 | 98 | npmBuildCommand = 99 | guard namedScript $> ScriptCommand (npmCmd <> " run -s " <> cmd) 100 | 101 | buildCommand = BuildCommand (spagoCmd <> " " <> cmd) [] 102 | 103 | pure $ fromMaybe buildCommand (npmSpecificCommand <|> npmBuildCommand) 104 | 105 | -- | Accepts defaults options and 106 | buildOptions 107 | :: PscidOptions 108 | -> { port :: Maybe Int 109 | , testAfterRebuild :: Boolean 110 | , includes :: String 111 | , outputDirectory :: String 112 | , censor :: String 113 | } 114 | -> PscidOptions 115 | buildOptions defaults { port, testAfterRebuild, includes, outputDirectory, censor } = do 116 | { port 117 | , testAfterRebuild 118 | , sourceDirectories: defaults.sourceDirectories <> includesArr 119 | , censorCodes: sepArguments "," censor 120 | , outputDirectory 121 | , buildCommand: setCommandIncludes includesArr defaults.buildCommand 122 | , testCommand: setCommandIncludes includesArr defaults.testCommand 123 | } 124 | where 125 | includesArr = sepArguments ";" includes 126 | 127 | sepArguments :: String -> String -> Array String 128 | sepArguments sep = 129 | Array.filter (not String.null) <<< String.split (String.Pattern sep) 130 | 131 | -- | A parser for pscid's options. A `Nothing` signals the user 132 | -- | requested the version 133 | options :: PscidOptions -> OA.Parser (Maybe PscidOptions) 134 | options defaults = ado 135 | displayVersion <- OA.switch 136 | ( OA.long "version" 137 | <> OA.help "Displays the version of this program" 138 | ) 139 | port <- optional 140 | ( OA.option 141 | OA.int 142 | ( OA.long "port" 143 | <> OA.short 'p' 144 | <> OA.metavar "PORT" 145 | <> OA.help "What port to start the ide server on" 146 | ) 147 | ) 148 | testAfterRebuild <- OA.switch 149 | ( OA.long "test" 150 | <> OA.help "Run tests after successful rebuild" 151 | ) 152 | includes <- 153 | OA.strOption 154 | ( OA.long "include" 155 | <> OA.short 'I' 156 | <> OA.help "Directories for additional PureScript source files, separated by `;`" 157 | <> OA.value "" 158 | <> OA.metavar "INCLUDES" 159 | ) 160 | <|> pure "" 161 | censor <- 162 | OA.strOption 163 | ( OA.long "censor-codes" 164 | <> OA.help "Warning codes to ignore, seperated by `,`" 165 | <> OA.value "" 166 | <> OA.metavar "CENSOR-CODES" 167 | ) 168 | <|> pure "" 169 | outputDirectory <- 170 | OA.strOption 171 | ( OA.long "output" 172 | <> OA.short 'O' 173 | <> OA.help "Output directory for compiled JavaScript" 174 | <> OA.value "output" 175 | <> OA.metavar "OUTPUT" 176 | ) 177 | <|> pure "output" 178 | in 179 | if displayVersion then Nothing 180 | else Just (buildOptions defaults { port, testAfterRebuild, outputDirectory, includes, censor }) 181 | 182 | optionParser :: Effect PscidOptions 183 | optionParser = do 184 | defaults <- mkDefaultOptions 185 | OA.execParser (opts defaults) >>= case _ of 186 | Nothing -> do 187 | Console.log =<< version 188 | Process.exit 0 189 | Just os -> 190 | pure os 191 | where 192 | opts defaults = OA.info (options defaults OA.<**> OA.helper) 193 | ( OA.fullDesc 194 | <> OA.progDesc "Watches and rebuilds PureScript source files" 195 | <> OA.header "pscid - A lightweight, fast and unintrusive PureScript file-watcher" 196 | ) 197 | 198 | foreign import hasNamedScript :: String -> Effect Boolean 199 | foreign import glob :: String -> Effect (Array String) 200 | foreign import version :: Effect String 201 | -------------------------------------------------------------------------------- /src/Pscid/Process.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Process where 2 | 3 | import Prelude 4 | 5 | import Ansi.Codes (Color(..)) 6 | import Data.Array as Array 7 | import Data.Maybe (fromJust) 8 | import Data.String as String 9 | import Effect (Effect) 10 | import Effect.Console as Console 11 | import Effect.Ref as Ref 12 | import Node.ChildProcess (Exit(..), defaultSpawnOptions, onExit, spawn, stderr, stdout) 13 | import Node.Encoding (Encoding(..)) 14 | import Node.Stream (onDataString) 15 | import Partial.Unsafe (unsafePartial) 16 | import Pscid.Console (logColored) 17 | import Pscid.Error (catchLog) 18 | 19 | execCommand :: String -> String -> Effect Unit 20 | execCommand name command = catchLog (name <> " threw an exception") do 21 | let cmd = unsafePartial fromJust (Array.uncons (String.split (String.Pattern " ") command)) 22 | output <- Ref.new "" 23 | Console.log ("Running: \"" <> command <> "\"") 24 | cp <- spawn cmd.head cmd.tail defaultSpawnOptions 25 | 26 | let 27 | stout = stdout cp 28 | sterr = stderr cp 29 | 30 | onDataString stout UTF8 \s -> 31 | Ref.modify_ (_ <> s) output 32 | 33 | onDataString sterr UTF8 \s -> 34 | Ref.modify_ (_ <> s) output 35 | 36 | onExit cp \e -> case e of 37 | Normally 0 -> logColored Green (name <> " successful!") 38 | Normally code -> do 39 | Console.log =<< Ref.read output 40 | logColored Red (name <> " errored with code: " <> show code) 41 | BySignal _ -> pure unit 42 | -------------------------------------------------------------------------------- /src/Pscid/Psa.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Psa 2 | ( module Psa 3 | , parseErrors 4 | , psaPrinter 5 | , filterWarnings 6 | ) where 7 | 8 | import Prelude 9 | 10 | import Data.Argonaut (Json, decodeJson, printJsonDecodeError) 11 | import Data.Array as Array 12 | import Data.Bifunctor (lmap) 13 | import Data.Either (Either) 14 | import Data.Maybe (Maybe(..), fromMaybe) 15 | import Data.Maybe as Maybe 16 | import Data.Set as Set 17 | import Data.String as String 18 | import Data.Traversable (traverse) 19 | import Effect (Effect) 20 | import Effect.Console as Console 21 | import Effect.Exception (catchException) 22 | import Node.Encoding as Encoding 23 | import Node.FS.Sync as File 24 | import Psa (Output, PsaError, PsaOptions, PsaResult, StatVerbosity(..), Suggestion, output, parsePsaError) 25 | import Psa.Printer (renderAnsi, renderRow) 26 | import Psa.Printer.Default (renderError, renderWarning) 27 | import Psa.Util (iter_) 28 | 29 | defaultOptions :: PsaOptions 30 | defaultOptions = 31 | { ansi: true 32 | , censorWarnings: false 33 | , censorLib: false 34 | , censorSrc: false 35 | , censorCodes: Set.empty 36 | , filterCodes: Set.empty 37 | , libDirs: [] 38 | , strict: false 39 | , cwd: "" 40 | , statVerbosity: NoStats 41 | } 42 | 43 | print :: String -> PsaOptions -> Output -> Effect Unit 44 | print successMessage options { warnings, errors } = do 45 | iter_ warnings \_ warning -> do 46 | Console.error (toString (renderWarning 1 1 warning)) 47 | Console.error "" 48 | 49 | iter_ errors \_ error -> do 50 | Console.error (toString (renderError 1 1 error)) 51 | Console.error "" 52 | 53 | when (Array.null warnings && Array.null errors) 54 | (Console.error successMessage) 55 | where 56 | toString = renderRow (String.joinWith "" <<< map (renderAnsi options.ansi)) 57 | 58 | parseErrors :: Json -> Either String (Array PsaError) 59 | parseErrors j = traverse parsePsaError =<< (lmap printJsonDecodeError $ decodeJson j) 60 | 61 | emptyResult :: PsaResult 62 | emptyResult = { warnings: [], errors: [] } 63 | 64 | wrapError :: Boolean -> PsaError -> PsaResult 65 | wrapError b e = 66 | if b then { warnings: [], errors: [ e ] } 67 | else { warnings: [ e ], errors: [] } 68 | 69 | psaPrinter :: String -> Boolean -> Array PsaError -> Effect Unit 70 | psaPrinter successMessage isError errs = 71 | catchException (const (Console.error "An error inside psaPrinter")) do 72 | out' <- output loadLines defaultOptions result 73 | print successMessage defaultOptions out' 74 | where 75 | result = fromMaybe emptyResult (wrapError isError <$> Array.head errs) 76 | 77 | loadLines 78 | :: forall a 79 | . String 80 | -> { startLine :: Int, endLine :: Int | a } 81 | -> Effect (Maybe (Array String)) 82 | loadLines filename pos = do 83 | contents <- String.split (String.Pattern "\n") <$> File.readTextFile Encoding.UTF8 filename 84 | let source = Array.slice (pos.startLine - 1) (pos.endLine) contents 85 | pure (Just source) 86 | 87 | -- | We do this to push imports suggestions for the Prelude to the 88 | -- | back of the suggestions, so all other modules can be explicitly 89 | -- | imported first through the suggestions 90 | reorderWarnings :: Array PsaError -> Array PsaError 91 | reorderWarnings errors = 92 | let 93 | isPreludeImport :: Suggestion -> Boolean 94 | isPreludeImport { replacement } = 95 | -- We're checking for two patterns to also catch the case of 96 | -- custom preludes that still have Prelude in their name 97 | ( String.contains (String.Pattern "import") 98 | && String.contains (String.Pattern "Prelude") 99 | ) replacement 100 | { yes, no } = 101 | Array.partition (Maybe.maybe false isPreludeImport <<< _.suggestion) errors 102 | in 103 | no <> yes 104 | 105 | -- | Removes warnings that have their error codes ignored and moves 106 | -- | import suggestions for the Prelude to the back 107 | filterWarnings :: Array String -> Array PsaError -> Array PsaError 108 | filterWarnings ignored errors = 109 | reorderWarnings (Array.filter (\e -> e.errorCode `Array.notElem` ignored) errors) 110 | -------------------------------------------------------------------------------- /src/Pscid/Server.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Server 2 | ( restartServer 3 | , startServer' 4 | , stopServer' 5 | , module PscIde.Server 6 | ) where 7 | 8 | import Prelude 9 | 10 | import Control.Alt ((<|>)) 11 | import Data.Either (Either(..)) 12 | import Data.Maybe (Maybe(..), maybe) 13 | import Effect.Aff (Aff, attempt) 14 | import Effect.Class (liftEffect) 15 | import Effect.Console as Console 16 | import Effect.Exception (try) 17 | import Node.ChildProcess as CP 18 | import Node.Process as Process 19 | import PscIde as PscIde 20 | import PscIde.Command (Message(..)) 21 | import PscIde.Server (ServerStartResult(..), defaultServerArgs, deleteSavedPort, getSavedPort, pickFreshPort, savePort, startServer, stopServer) 22 | 23 | stopServer' :: Int -> Aff Unit 24 | stopServer' port = do 25 | _ <- liftEffect (Process.cwd >>= try <<< deleteSavedPort) 26 | stopServer port 27 | 28 | startServer' 29 | :: Maybe Int 30 | -> String 31 | -> Aff (Either String Int) 32 | startServer' optPort outputDir = do 33 | dir <- liftEffect Process.cwd 34 | port <- liftEffect (getSavedPort dir) 35 | case optPort <|> port of 36 | Just p -> do 37 | workingDir <- attempt (PscIde.cwd p) 38 | case workingDir of 39 | -- If we find an already running server with the right working 40 | -- directory, we just return its port. 41 | Right (Right (Message dir')) | dir == dir' -> pure (Right p) 42 | -- Otherwise we start a new server 43 | _ -> launchServer dir 44 | Nothing -> launchServer dir 45 | 46 | where 47 | launchServer :: String -> Aff (Either String Int) 48 | launchServer dir = do 49 | newPort <- maybe (liftEffect pickFreshPort) pure optPort 50 | _ <- liftEffect (try (savePort newPort dir)) 51 | r newPort <$> 52 | startServer 53 | ( defaultServerArgs 54 | { port = Just newPort 55 | , cwd = Just dir 56 | , outputDirectory = Just outputDir 57 | , stdio = CP.ignore 58 | } 59 | ) 60 | where 61 | r newPort (Started _) = Right newPort 62 | r _ (Closed) = Left "Closed" 63 | r _ (StartError s) = Left s 64 | 65 | restartServer :: Int -> String -> Aff Unit 66 | restartServer port outputDir = do 67 | _ <- attempt (stopServer port) 68 | r <- attempt (startServer' (Just port) outputDir) 69 | liftEffect case r of 70 | Left e -> do 71 | Console.log 72 | ( "Failed to restart psc-ide-server on port: " <> show port 73 | <> "\nThe error was: " 74 | <> show e 75 | ) 76 | Process.exit 1 77 | Right _ -> Console.log "I restarted psc-ide-server for you." 78 | -------------------------------------------------------------------------------- /src/Pscid/Util.purs: -------------------------------------------------------------------------------- 1 | module Pscid.Util (both) where 2 | 3 | import Data.Either (Either(..)) 4 | 5 | both :: forall a b. (a -> b) -> Either a a -> Either b b 6 | both f e = case e of 7 | Right x -> Right (f x) 8 | Left x -> Left (f x) 9 | --------------------------------------------------------------------------------