mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-12-03 13:54:12 +03:00
[waspls] Add code actions for scaffolding external code (#1316)
When an external import tries to import a symbol from a TypeScript/JavaScript file, waspls now offers quickfix code actions to scaffold a function in that file.
It uses the surrounding context of the external import to determine what code to write for the code action. See [`ScaffoldTsSymbol.hs`](457911d5e9/waspc/waspls/src/Wasp/LSP/Commands/ScaffoldTsSymbol.hs
) for a detailed description of how it works. At a high level, there is a `templateForFile` function in `Wasp.LSP.Commands.ScaffoldTsSymbol` that selects the correct template from `data/lsp/templates/ts`. For example, `action.fn.ts` contains a template for scaffolding an `action` function in a TypeScript file and would be used when a code action is requested with the cursor at the location marked by `|`:
```wasp
action createTask {
fn: import { createTask } from "@server/actions.js"|
}
```
The scaffold action runs as a [LSP command](https://microsoft.github.io/language-server-protocol/specifications/specification-3-16/#workspace_executeCommand). To prepare for wanting to define more commands in waspls in the future, this PR also introduces the concept of `Commands` (`Wasp.LSP.Commands.Command`) that define some properties about each command waspls wants to handle.
This commit is contained in:
parent
3e481eba04
commit
76d9fc4213
9
waspc/data/lsp/templates/ts/action.fn.ts
Normal file
9
waspc/data/lsp/templates/ts/action.fn.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { {{upperDeclName}} } from '@wasp/actions/types'
|
||||
|
||||
{{#named?}}export {{/named?}}const {{name}}: {{upperDeclName}}<void, void> = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
7
waspc/data/lsp/templates/ts/operation.fn.js
Normal file
7
waspc/data/lsp/templates/ts/operation.fn.js
Normal file
@ -0,0 +1,7 @@
|
||||
{{#named?}}export {{/named?}}const {{name}} = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
9
waspc/data/lsp/templates/ts/page.component.jsx
Normal file
9
waspc/data/lsp/templates/ts/page.component.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
{{#named?}}export {{/named?}}function {{name}}() {
|
||||
return (
|
||||
<div>Hello world!</div>
|
||||
)
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
9
waspc/data/lsp/templates/ts/query.fn.ts
Normal file
9
waspc/data/lsp/templates/ts/query.fn.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { {{upperDeclName}} } from '@wasp/queries/types'
|
||||
|
||||
{{#named?}}export {{/named?}}const {{name}}: {{upperDeclName}}<void, void> = async (args, context) => {
|
||||
// Implementation goes here
|
||||
}
|
||||
|
||||
{{#default?}}
|
||||
export default {{name}}
|
||||
{{/default?}}
|
@ -2,6 +2,7 @@
|
||||
|
||||
module Wasp.Analyzer.Parser.SourceSpan
|
||||
( SourceSpan (..),
|
||||
spansOverlap,
|
||||
)
|
||||
where
|
||||
|
||||
@ -18,3 +19,6 @@ data SourceSpan = SourceSpan !SourceOffset !SourceOffset
|
||||
deriving (Eq, Ord, Show, Generic)
|
||||
|
||||
instance NFData SourceSpan
|
||||
|
||||
spansOverlap :: SourceSpan -> SourceSpan -> Bool
|
||||
spansOverlap (SourceSpan s0 e0) (SourceSpan s1 e1) = not ((s1 >= e0) || (s0 >= e1))
|
||||
|
@ -1,4 +1,6 @@
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
{-# LANGUAGE DeriveDataTypeable #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
|
||||
module Wasp.AppSpec.ExtImport
|
||||
( ExtImport (..),
|
||||
@ -7,7 +9,9 @@ module Wasp.AppSpec.ExtImport
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import Data.Data (Data)
|
||||
import GHC.Generics (Generic)
|
||||
import StrongPath (File', Path, Posix, Rel)
|
||||
import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
|
||||
|
||||
@ -28,7 +32,7 @@ data ExtImportName
|
||||
ExtImportModule Identifier
|
||||
| -- | Represents external imports like @import { Identifier } from "file.js"@
|
||||
ExtImportField Identifier
|
||||
deriving (Show, Eq, Data)
|
||||
deriving (Show, Eq, Data, Generic, FromJSON, ToJSON)
|
||||
|
||||
importIdentifier :: ExtImport -> Identifier
|
||||
importIdentifier (ExtImport importName _) = case importName of
|
||||
|
22
waspc/src/Wasp/Util/HashMap.hs
Normal file
22
waspc/src/Wasp/Util/HashMap.hs
Normal file
@ -0,0 +1,22 @@
|
||||
module Wasp.Util.HashMap
|
||||
( lookupKey,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Foldable (find)
|
||||
import Data.HashMap.Strict (HashMap)
|
||||
import qualified Data.HashMap.Strict as M
|
||||
|
||||
-- | Lookup a key, returning the key stored in the map. Useful when the 'Eq'
|
||||
-- instance on the key type isn't structural equality.
|
||||
--
|
||||
-- === __Example__
|
||||
-- >>> {-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
-- >>> import Data.Hashable (Hashable)
|
||||
-- >>> newtype ApproxDouble = ApproxDouble Double deriving (Show, Hashable)
|
||||
-- >>> instance Eq ApproxDouble where
|
||||
-- >>> ApproxDouble x == ApproxDouble y = abs (x - y) < 0.01
|
||||
-- >>> lookupKey (ApproxDouble 0.502) $ M.fromList [(ApproxDouble 0.5, "a"), (ApproxDouble 0.6, "b")]
|
||||
-- 0.5
|
||||
lookupKey :: (Eq k) => k -> HashMap k v -> Maybe k
|
||||
lookupKey k = find (== k) . M.keys
|
19
waspc/src/Wasp/Util/StrongPath.hs
Normal file
19
waspc/src/Wasp/Util/StrongPath.hs
Normal file
@ -0,0 +1,19 @@
|
||||
module Wasp.Util.StrongPath
|
||||
( replaceExtension,
|
||||
stripProperPrefix,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad.Catch (MonadThrow)
|
||||
import qualified Path as P
|
||||
import qualified StrongPath as SP
|
||||
import qualified StrongPath.Path as SP
|
||||
|
||||
stripProperPrefix :: SP.Path' SP.Abs (SP.Dir a) -> SP.Path' SP.Abs (SP.File b) -> Maybe (SP.Path' (SP.Rel a) (SP.File b))
|
||||
stripProperPrefix base file =
|
||||
SP.fromPathRelFile
|
||||
<$> P.stripProperPrefix (SP.toPathAbsDir base) (SP.toPathAbsFile file)
|
||||
|
||||
replaceExtension :: MonadThrow m => SP.Path' SP.Abs (SP.File a) -> String -> m (SP.Path' SP.Abs (SP.File a))
|
||||
replaceExtension path ext =
|
||||
SP.fromPathAbsFile <$> P.replaceExtension ext (SP.toPathAbsFile path)
|
27
waspc/test/Analyzer/Parser/SourceSpanTest.hs
Normal file
27
waspc/test/Analyzer/Parser/SourceSpanTest.hs
Normal file
@ -0,0 +1,27 @@
|
||||
module Analyzer.Parser.SourceSpanTest where
|
||||
|
||||
import Test.QuickCheck
|
||||
import Test.Tasty.Hspec
|
||||
import Wasp.Analyzer.Parser.SourceSpan (SourceSpan (SourceSpan), spansOverlap)
|
||||
|
||||
spec_SourceSpanTest :: Spec
|
||||
spec_SourceSpanTest = do
|
||||
describe "Analyzer.Parser.SourceSpan" $ do
|
||||
describe "spansOverlap works" $ do
|
||||
it "when first is before second" $ do
|
||||
spansOverlap (SourceSpan 0 5) (SourceSpan 10 15) `shouldBe` False
|
||||
it "when first ends right before second starts" $ do
|
||||
spansOverlap (SourceSpan 0 5) (SourceSpan 5 10) `shouldBe` False
|
||||
it "when first overlaps second on its left edge" $ do
|
||||
spansOverlap (SourceSpan 0 5) (SourceSpan 4 10) `shouldBe` True
|
||||
it "when first is second" $ do
|
||||
spansOverlap (SourceSpan 0 5) (SourceSpan 0 5) `shouldBe` True
|
||||
it "when first overlaps second on its right edge" $ do
|
||||
spansOverlap (SourceSpan 4 10) (SourceSpan 0 5) `shouldBe` True
|
||||
it "when second is zero-width" $ do
|
||||
spansOverlap (SourceSpan 0 5) (SourceSpan 2 2) `shouldBe` True
|
||||
it "is commutative" $ do
|
||||
property $ \s0 e0 s1 e1 ->
|
||||
let first = SourceSpan s0 e0
|
||||
second = SourceSpan s1 e1
|
||||
in spansOverlap first second == spansOverlap second first
|
@ -53,6 +53,9 @@ data-files:
|
||||
Cli/templates/basic/.wasproot
|
||||
Cli/templates/basic/src/.waspignore
|
||||
Cli/templates/basic/main.wasp
|
||||
lsp/templates/**/*.js
|
||||
lsp/templates/**/*.ts
|
||||
lsp/templates/**/*.jsx
|
||||
packages/deploy/dist/**/*.js
|
||||
packages/deploy/package.json
|
||||
packages/deploy/package-lock.json
|
||||
@ -331,6 +334,8 @@ library
|
||||
Wasp.Util.IO
|
||||
Wasp.Util.Terminal
|
||||
Wasp.Util.FilePath
|
||||
Wasp.Util.StrongPath
|
||||
Wasp.Util.HashMap
|
||||
Wasp.WaspignoreFile
|
||||
Wasp.Generator.NpmDependencies
|
||||
Wasp.Generator.NpmInstall
|
||||
@ -344,6 +349,10 @@ library waspls
|
||||
Control.Monad.Log
|
||||
Control.Monad.Log.Class
|
||||
Wasp.LSP.Analysis
|
||||
Wasp.LSP.CodeActions
|
||||
Wasp.LSP.Commands
|
||||
Wasp.LSP.Commands.Command
|
||||
Wasp.LSP.Commands.ScaffoldTsSymbol
|
||||
Wasp.LSP.Completion
|
||||
Wasp.LSP.Completions.Common
|
||||
Wasp.LSP.Completions.DictKeyCompletion
|
||||
@ -383,6 +392,7 @@ library waspls
|
||||
, strong-path
|
||||
, path
|
||||
, async ^>=2.2.4
|
||||
, mustache ^>=2.3.2
|
||||
, unliftio-core
|
||||
, mtl
|
||||
, text
|
||||
@ -516,6 +526,7 @@ test-suite waspc-test
|
||||
Analyzer.Parser.CST.TraverseTest
|
||||
Analyzer.Parser.ParseErrorTest
|
||||
Analyzer.Parser.SourcePositionTest
|
||||
Analyzer.Parser.SourceSpanTest
|
||||
Analyzer.ParserTest
|
||||
Analyzer.TestUtil
|
||||
Analyzer.TypeChecker.InternalTest
|
||||
@ -577,6 +588,7 @@ test-suite waspls-test
|
||||
other-modules:
|
||||
Wasp.LSP.CompletionTest
|
||||
Wasp.LSP.DebouncerTest
|
||||
Wasp.LSP.SyntaxTest
|
||||
|
||||
test-suite cli-test
|
||||
import: common-all, common-exe
|
||||
|
153
waspc/waspls/src/Wasp/LSP/CodeActions.hs
Normal file
153
waspc/waspls/src/Wasp/LSP/CodeActions.hs
Normal file
@ -0,0 +1,153 @@
|
||||
module Wasp.LSP.CodeActions
|
||||
( getCodeActionsInRange,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Lens ((^.))
|
||||
import Control.Monad (filterM)
|
||||
import Control.Monad.IO.Class (MonadIO (liftIO))
|
||||
import Control.Monad.Reader.Class (asks)
|
||||
import Data.Maybe (catMaybes, fromMaybe)
|
||||
import qualified Data.Text as Text
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified StrongPath as SP
|
||||
import Text.Printf (printf)
|
||||
import Wasp.Analyzer.Parser.AST (ExtImportName (ExtImportField, ExtImportModule))
|
||||
import Wasp.Analyzer.Parser.CST (SyntaxNode)
|
||||
import qualified Wasp.Analyzer.Parser.CST as S
|
||||
import Wasp.Analyzer.Parser.CST.Traverse (Traversal)
|
||||
import qualified Wasp.Analyzer.Parser.CST.Traverse as T
|
||||
import Wasp.Analyzer.Parser.SourceSpan (SourceSpan, spansOverlap)
|
||||
import qualified Wasp.LSP.Commands.ScaffoldTsSymbol as ScaffoldTS
|
||||
import Wasp.LSP.ExtImport.ExportsCache (ExtImportLookupResult (..), lookupExtImport)
|
||||
import Wasp.LSP.ExtImport.Path (WaspStyleExtFilePath)
|
||||
import qualified Wasp.LSP.ExtImport.Path as ExtImport
|
||||
import Wasp.LSP.ExtImport.Syntax (ExtImportNode (einLocation, einName), extImportAtLocation)
|
||||
import Wasp.LSP.ServerMonads (HandlerM)
|
||||
import qualified Wasp.LSP.ServerState as State
|
||||
import Wasp.LSP.Syntax (lspRangeToSpan)
|
||||
import qualified Wasp.LSP.TypeInference as Inference
|
||||
import Wasp.LSP.Util (getPathRelativeToProjectDir)
|
||||
import qualified Wasp.Util.HashMap as M
|
||||
import Wasp.Util.IO (doesFileExist)
|
||||
import Wasp.Util.StrongPath (replaceExtension)
|
||||
|
||||
-- | Runs all 'codeActionProviders' and concatenates their results.
|
||||
getCodeActionsInRange :: LSP.Range -> HandlerM [LSP.CodeAction]
|
||||
getCodeActionsInRange range = do
|
||||
src <- asks (^. State.currentWaspSource)
|
||||
maybeCst <- asks (^. State.cst)
|
||||
let sourceSpan = lspRangeToSpan src range
|
||||
case maybeCst of
|
||||
Nothing -> pure []
|
||||
Just syntax -> do
|
||||
concat <$> mapM (\provider -> provider src syntax sourceSpan) codeActionProviders
|
||||
|
||||
codeActionProviders :: [String -> [SyntaxNode] -> SourceSpan -> HandlerM [LSP.CodeAction]]
|
||||
codeActionProviders =
|
||||
[ extImportScaffoldActionProvider
|
||||
]
|
||||
|
||||
-- | Provide 'LSP.CodeAction's that define missing JS/TS functions that do not
|
||||
-- already exist but are needed by external imports that are found within the
|
||||
-- given 'SourceSpan'.
|
||||
extImportScaffoldActionProvider :: String -> [SyntaxNode] -> SourceSpan -> HandlerM [LSP.CodeAction]
|
||||
extImportScaffoldActionProvider src syntax sourceSpan = do
|
||||
let extImports = map (extImportAtLocation src) $ collectExtImportNodesInSpan (T.fromSyntaxForest syntax)
|
||||
concat <$> mapM (getScaffoldActionsForExtImport src) extImports
|
||||
where
|
||||
-- Post-condition: all 'Traversal's returned have @kindAt t == ExtImport@.
|
||||
collectExtImportNodesInSpan :: Traversal -> [Traversal]
|
||||
collectExtImportNodesInSpan t =
|
||||
-- Only consider the given traversal if it is within the span.
|
||||
if spansOverlap (T.spanAt t) sourceSpan
|
||||
then case T.kindAt t of
|
||||
S.ExtImport -> [t]
|
||||
_ -> concatMap collectExtImportNodesInSpan $ T.children t
|
||||
else []
|
||||
|
||||
-- | Finds code actions to create a JS/TS function for an external import. Returns
|
||||
-- one code action for each JS/TS/JSX/TSX file a function can be created in.
|
||||
--
|
||||
-- This function also checks to make sure each code action, which runs the
|
||||
-- @wasp.scaffold.ts-symbol@ command, has a scaffolding template that can be
|
||||
-- used.
|
||||
getScaffoldActionsForExtImport :: String -> ExtImportNode -> HandlerM [LSP.CodeAction]
|
||||
getScaffoldActionsForExtImport src extImport = do
|
||||
lookupExtImport extImport >>= \case
|
||||
ImportSyntaxError -> return []
|
||||
ImportCacheMiss -> return [] -- Not in cache, so we assume it's valid.
|
||||
ImportsSymbol _ _ -> return [] -- Valid import, no code action needed.
|
||||
ImportedFileDoesNotExist waspStylePath -> case einName extImport of
|
||||
Nothing -> return [] -- Syntax error in import, can't know if it's valid.
|
||||
Just symbolName -> makeCodeActions True symbolName waspStylePath
|
||||
ImportedSymbolDoesNotExist symbolName waspStylePath -> makeCodeActions False symbolName waspStylePath
|
||||
where
|
||||
makeCodeActions :: Bool -> ExtImportName -> WaspStyleExtFilePath -> HandlerM [LSP.CodeAction]
|
||||
makeCodeActions createNewFile symbolName waspStylePath = do
|
||||
let pathToExtImport = fromMaybe [] $ Inference.findExprPathToLocation src $ einLocation extImport
|
||||
referencedPaths <- getPathsReferencedByExtImport createNewFile waspStylePath
|
||||
catMaybes <$> mapM (makeCodeAction symbolName pathToExtImport) referencedPaths
|
||||
|
||||
-- Get the list of paths the client might want to define the function in. If
|
||||
-- a file with the right name already exists, it returns that one.
|
||||
--
|
||||
-- If no file exists, returns a list of files with each allowed extension,
|
||||
-- based on JS module resolution. For example, @import x from "@server/y.js"@
|
||||
-- results in both @y.ts@ and @y.js@.
|
||||
--
|
||||
-- See "Wasp.LSP.ExtImport.Path" for how allowed extensions are decided based
|
||||
-- on the path written in the Wasp source code.
|
||||
getPathsReferencedByExtImport :: Bool -> WaspStyleExtFilePath -> HandlerM [SP.Path' SP.Abs SP.File']
|
||||
getPathsReferencedByExtImport createNewFile waspStylePath = do
|
||||
let cachePathFromSrc =
|
||||
fromMaybe (error "[createCodeActions] unreachable: invalid wasp style path") $
|
||||
ExtImport.waspStylePathToCachePath waspStylePath
|
||||
-- The allowed extensions stored in the cache are based on what files exist
|
||||
-- on disk. Usually, paths in the cache will allow only one extension, which
|
||||
-- will be exactly the extension that is used on the file system.
|
||||
allowedExts <-
|
||||
ExtImport.allowedExts . ExtImport.cachePathExtType . fromMaybe cachePathFromSrc
|
||||
<$> asks (M.lookupKey cachePathFromSrc . (^. State.tsExports))
|
||||
absPath <-
|
||||
fromMaybe (error "[createCodeActions] unreachable: can't get abs path")
|
||||
<$> ExtImport.cachePathToAbsPathWithoutExt cachePathFromSrc
|
||||
let possiblePaths =
|
||||
map (SP.castFile . fromMaybe (error "unreachable") . replaceExtension absPath) allowedExts
|
||||
|
||||
-- If not creating a new file, the external import could only be referencing an
|
||||
-- existing file, so nonexistant files are filtered.
|
||||
if createNewFile
|
||||
then return possiblePaths
|
||||
else filterM (liftIO . doesFileExist) possiblePaths
|
||||
|
||||
-- Checks if the "ScaffoldTS" command has a template for this request; if it
|
||||
-- does not, returns 'Nothing'.
|
||||
makeCodeAction :: ExtImportName -> Inference.ExprPath -> SP.Path' SP.Abs (SP.File a) -> HandlerM (Maybe LSP.CodeAction)
|
||||
makeCodeAction symbolName pathToExtImport targetFile = do
|
||||
-- The code action is nicer to use when we display just the relative path to the file.
|
||||
targetFileForDisplay <- maybe (SP.fromAbsFile targetFile) SP.fromRelFile <$> getPathRelativeToProjectDir targetFile
|
||||
let args =
|
||||
ScaffoldTS.Args
|
||||
{ ScaffoldTS.symbolName = symbolName,
|
||||
ScaffoldTS.pathToExtImport = pathToExtImport,
|
||||
ScaffoldTS.filepath = SP.castFile targetFile
|
||||
}
|
||||
command = ScaffoldTS.makeLspCommand args
|
||||
let title = case symbolName of
|
||||
ExtImportModule _ -> printf "Add default export to %s" targetFileForDisplay
|
||||
ExtImportField name -> printf "Create function `%s` in %s" name targetFileForDisplay
|
||||
if ScaffoldTS.hasTemplateForArgs args
|
||||
then
|
||||
return . Just $
|
||||
LSP.CodeAction
|
||||
{ _title = Text.pack title,
|
||||
_kind = Just LSP.CodeActionQuickFix,
|
||||
_diagnostics = Nothing,
|
||||
_isPreferred = Nothing,
|
||||
_disabled = Nothing,
|
||||
_edit = Nothing,
|
||||
_command = Just command,
|
||||
_xdata = Nothing
|
||||
}
|
||||
else return Nothing
|
58
waspc/waspls/src/Wasp/LSP/Commands.hs
Normal file
58
waspc/waspls/src/Wasp/LSP/Commands.hs
Normal file
@ -0,0 +1,58 @@
|
||||
module Wasp.LSP.Commands
|
||||
( -- * waspls Commands
|
||||
|
||||
-- Defines 'handleExecuteCommand', which dispatches a LSP workspace/executeCommand
|
||||
-- request to the appropriate 'Command'.
|
||||
--
|
||||
-- To define a new command, create a "Wasp.LSP.Commands.Command" for
|
||||
-- it and add it to 'commands' list in this module.
|
||||
--
|
||||
-- When defining a new command, it is recommended to, in addition to the
|
||||
-- 'Command', define an @Args@ type that the command expects to be
|
||||
-- passed to it and a @makeLspCommand@ function that takes an @Args@ value and
|
||||
-- returns an 'LSP.Command'. Following this pattern will ensure a simple
|
||||
-- and consistent interface to interacting with each command.
|
||||
availableCommands,
|
||||
handleExecuteCommand,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Lens ((^.))
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as Text
|
||||
import qualified Language.LSP.Server as LSP
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified Language.LSP.Types.Lens as LSP
|
||||
import Text.Printf (printf)
|
||||
import Wasp.LSP.Commands.Command (Command (commandHandler, commandName))
|
||||
import qualified Wasp.LSP.Commands.ScaffoldTsSymbol as ScaffoldTsSymbol
|
||||
import Wasp.LSP.ServerMonads (ServerM)
|
||||
|
||||
commands :: M.HashMap Text Command
|
||||
commands =
|
||||
M.fromList $
|
||||
map
|
||||
(\command -> (commandName command, command))
|
||||
[ ScaffoldTsSymbol.command
|
||||
]
|
||||
|
||||
-- | List of the names of commands that 'handler' can execute.
|
||||
availableCommands :: [Text]
|
||||
availableCommands = M.keys commands
|
||||
|
||||
-- | Find the relevant 'Command' in 'commands' for the request, or respond
|
||||
-- with an error if there is no handler listed for it.
|
||||
handleExecuteCommand :: LSP.Handlers ServerM
|
||||
handleExecuteCommand = LSP.requestHandler LSP.SWorkspaceExecuteCommand $ \request respond ->
|
||||
let requestedCommand = request ^. LSP.params . LSP.command
|
||||
in case commands M.!? requestedCommand of
|
||||
Nothing -> do
|
||||
respond $
|
||||
Left $
|
||||
LSP.ResponseError
|
||||
{ _code = LSP.MethodNotFound,
|
||||
_message = Text.pack $ printf "No handler for command '%s'" requestedCommand,
|
||||
_xdata = Nothing
|
||||
}
|
||||
Just command -> commandHandler command request respond
|
71
waspc/waspls/src/Wasp/LSP/Commands/Command.hs
Normal file
71
waspc/waspls/src/Wasp/LSP/Commands/Command.hs
Normal file
@ -0,0 +1,71 @@
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
|
||||
module Wasp.LSP.Commands.Command
|
||||
( Command (Command, commandName, commandHandler),
|
||||
withParsedArgs,
|
||||
makeInvalidParamsError,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Lens ((^.))
|
||||
import Data.Aeson (FromJSON, Result (Error, Success), Value, fromJSON)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as Text
|
||||
import qualified Language.LSP.Server as LSP
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified Language.LSP.Types.Lens as LSP
|
||||
import Wasp.LSP.ServerMonads (ServerM)
|
||||
|
||||
-- | Command name and handler. When a 'LSP.WorkspaceExecuteCommand' request is
|
||||
-- received with the command name matching the one listed in a 'Command', the
|
||||
-- corresponding handler is executed.
|
||||
data Command = Command
|
||||
{ commandName :: Text,
|
||||
commandHandler :: LSP.Handler ServerM 'LSP.WorkspaceExecuteCommand
|
||||
}
|
||||
|
||||
-- | @withParsedArgs request respond handleUsingArgs@ parses the arguments list
|
||||
-- of a 'LSP.WorkspaceExecuteCommand' request according to the following rules
|
||||
-- and passes the parsed arguments to @handleUsingArgs@.
|
||||
--
|
||||
-- Parsing rules:
|
||||
-- - The request contains exactly one JSON argument value.
|
||||
-- - The single JSON argument can be parsed into the type that @handleUsingArgs@
|
||||
-- expects to be passed.
|
||||
--
|
||||
-- When a request does not meet these requirements, a 'LSP.ResponseError' is
|
||||
-- sent to the client and @handleUsingArgs@ is not run.
|
||||
--
|
||||
-- == Usage
|
||||
-- This function is inteneded to be wrapped around the top-level of a command
|
||||
-- handler:
|
||||
--
|
||||
-- @
|
||||
-- data Args = Args { message :: String } deriving (Generic, FromJSON)
|
||||
--
|
||||
-- handle request response = withParsedArgs request response $ \args -> do
|
||||
-- logM $ "received message " <> message args
|
||||
-- -- ...
|
||||
-- @
|
||||
withParsedArgs ::
|
||||
(FromJSON args, LSP.MonadLsp c m) =>
|
||||
-- | LSP 'request'.
|
||||
LSP.RequestMessage 'LSP.WorkspaceExecuteCommand ->
|
||||
-- | LSP 'respond'.
|
||||
(Either LSP.ResponseError Value -> m ()) ->
|
||||
-- | Handler that need arguments.
|
||||
(args -> m ()) ->
|
||||
m ()
|
||||
withParsedArgs request respond handleCmdUsingArgs = case request ^. LSP.params . LSP.arguments of
|
||||
Just (LSP.List [jsonArgument]) -> case fromJSON jsonArgument of
|
||||
Error err -> respond $ Left $ makeInvalidParamsError $ Text.pack err
|
||||
Success parsedArgs -> handleCmdUsingArgs parsedArgs
|
||||
_ -> respond $ Left $ makeInvalidParamsError "Expected exactly one argument"
|
||||
|
||||
makeInvalidParamsError :: Text -> LSP.ResponseError
|
||||
makeInvalidParamsError msg =
|
||||
LSP.ResponseError
|
||||
{ _code = LSP.InvalidParams,
|
||||
_message = msg,
|
||||
_xdata = Nothing
|
||||
}
|
246
waspc/waspls/src/Wasp/LSP/Commands/ScaffoldTsSymbol.hs
Normal file
246
waspc/waspls/src/Wasp/LSP/Commands/ScaffoldTsSymbol.hs
Normal file
@ -0,0 +1,246 @@
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
|
||||
module Wasp.LSP.Commands.ScaffoldTsSymbol
|
||||
( -- * Scaffold TS Symbol Command
|
||||
|
||||
-- Command @wasp.scaffold.ts-symbol@ appends a new function with the given
|
||||
-- name to end of a particular file.
|
||||
--
|
||||
-- Mustache templates are used to write the code for the new function.
|
||||
-- @templateForRequest@ chooses a template in @data/lsp/template/ts@ based
|
||||
-- on the location of the external import and the extension of the file that
|
||||
-- the function will be appended to.
|
||||
--
|
||||
-- To add a new template, add the template to the template directory. Then,
|
||||
-- add a new equation to @templateForRequest@ that matches when you want the
|
||||
-- template to be used.
|
||||
--
|
||||
-- The templates have several variables available to them:
|
||||
--
|
||||
-- [@name@]: String, the name imported by a the external import. For example,
|
||||
-- @getAll@ in @import { getAll } from "@server/queries.js"@.
|
||||
--
|
||||
-- [@default?@]: Boolean, true if the external import is importing the default
|
||||
-- export.
|
||||
--
|
||||
-- [@named?@]: Boolean, true if the external import is importing a named export.
|
||||
--
|
||||
-- [@upperDeclName@]: String, the name of the declaration the external import
|
||||
-- is within, with the first letter capitalized.
|
||||
|
||||
-- ** Current Limitations
|
||||
|
||||
-- Due to the current way waspls works, we are not able to send a
|
||||
-- 'LSP.WorkspaceApplyEdit' request to add the scaffolded code. Instead, we
|
||||
-- modify the file on disk. This isn't the exact proper way to modify the
|
||||
-- code, but it works.
|
||||
--
|
||||
-- The reason is that waspls only receives document synchronization events
|
||||
-- for @.wasp@ files (didChange, didOpen, didClose, etc.), so we only know
|
||||
-- the versioned URI for the @.wasp@ file, not the TS files. But we need the
|
||||
-- versioned URI for sending edits. The LSP spec is not clear on what files
|
||||
-- get the sync events, but in VSCode's case it is determined by the client
|
||||
-- (the VSCode Wasp extension).
|
||||
--
|
||||
-- The extension can easily be configured to send sync events for JS/TS
|
||||
-- files, but waspls makes a lot of assumptions about what files it receives
|
||||
-- events for and would require a lot of refactoring to work properly. It
|
||||
-- would be best to handle this at the same time as we add support for multiple
|
||||
-- wasp files to the language and to waspls.
|
||||
Args (Args, symbolName, pathToExtImport, filepath),
|
||||
hasTemplateForArgs,
|
||||
command,
|
||||
makeLspCommand,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (void)
|
||||
import Control.Monad.Except (MonadError (throwError), runExceptT)
|
||||
import Control.Monad.IO.Class (MonadIO (liftIO))
|
||||
import Control.Monad.Log.Class (logM)
|
||||
import Data.Aeson (FromJSON, ToJSON (toJSON), object, parseJSON, withObject, (.:), (.=))
|
||||
import qualified Data.Aeson as Aeson
|
||||
import Data.Either (isRight)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as Text
|
||||
import qualified Data.Text.IO as Text
|
||||
import qualified Language.LSP.Server as LSP
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified Path as P
|
||||
import qualified StrongPath as SP
|
||||
import qualified StrongPath.Path as SP
|
||||
import qualified Text.Mustache as Mustache
|
||||
import Text.Printf (printf)
|
||||
import Wasp.Analyzer.Parser.AST (ExtImportName (ExtImportField, ExtImportModule))
|
||||
import qualified Wasp.Data
|
||||
import Wasp.LSP.Commands.Command (Command (Command, commandHandler, commandName), makeInvalidParamsError, withParsedArgs)
|
||||
import Wasp.LSP.ServerMonads (ServerM)
|
||||
import Wasp.LSP.TypeInference (ExprPath, ExprPathStep (Decl, DictKey))
|
||||
import qualified Wasp.LSP.TypeInference as Inference
|
||||
import Wasp.Util (toUpperFirst)
|
||||
import Wasp.Util.IO (doesFileExist)
|
||||
|
||||
command :: Command
|
||||
command =
|
||||
Command
|
||||
{ commandName = "wasp.scaffold.ts-symbol",
|
||||
commandHandler = handler
|
||||
}
|
||||
|
||||
makeLspCommand :: Args -> LSP.Command
|
||||
makeLspCommand args =
|
||||
LSP.Command
|
||||
{ _title = "Scaffold TS Code",
|
||||
_command = commandName command,
|
||||
_arguments = Just $ LSP.List [toJSON args]
|
||||
}
|
||||
|
||||
data Args = Args
|
||||
{ -- | Name of the symbol to define. If this is 'ExtImportModule', it will
|
||||
-- create a function with the specified name and export it as default.
|
||||
symbolName :: ExtImportName,
|
||||
-- | Description of where the external import occurs in the wasp source code,
|
||||
-- used, along with the extension of 'filepath', to determine what template
|
||||
-- to use.
|
||||
pathToExtImport :: ExprPath,
|
||||
-- | Path to the file to add the scaffolded code to the end of.
|
||||
filepath :: SP.Path' SP.Abs SP.File'
|
||||
}
|
||||
deriving (Show, Eq)
|
||||
|
||||
instance ToJSON Args where
|
||||
toJSON args =
|
||||
object
|
||||
[ "symbolName" .= symbolName args,
|
||||
"pathToExtImport" .= pathToExtImport args,
|
||||
"filepath" .= SP.toFilePath (filepath args)
|
||||
]
|
||||
|
||||
instance FromJSON Args where
|
||||
parseJSON = withObject "Args" $ \v ->
|
||||
Args
|
||||
<$> v .: "symbolName"
|
||||
<*> v .: "pathToExtImport"
|
||||
<*> ((maybe (fail "Could not parse filepath") pure . SP.parseAbsFile) =<< v .: "filepath")
|
||||
|
||||
handler :: LSP.Handler ServerM 'LSP.WorkspaceExecuteCommand
|
||||
handler request respond = withParsedArgs request respond scaffold
|
||||
where
|
||||
scaffold :: Args -> ServerM ()
|
||||
scaffold args@Args {..} = case P.fileExtension $ SP.toPathAbsFile filepath of
|
||||
Nothing -> respond $ Left $ makeInvalidParamsError "Invalid filepath: no extension"
|
||||
Just ext ->
|
||||
getTemplateFor pathToExtImport ext >>= \case
|
||||
Left err ->
|
||||
respond $ Left $ makeInvalidParamsError $ Text.pack err
|
||||
Right template -> renderAndWriteScaffoldTemplate args template
|
||||
|
||||
renderAndWriteScaffoldTemplate :: Args -> Mustache.Template -> ServerM ()
|
||||
renderAndWriteScaffoldTemplate args@Args {..} template = case pathToExtImport of
|
||||
Inference.Decl _ declName : _ -> do
|
||||
let symbolData = case symbolName of
|
||||
ExtImportModule name -> ["default?" .= True, "named?" .= False, "name" .= name]
|
||||
ExtImportField name -> ["default?" .= False, "named?" .= True, "name" .= name]
|
||||
let templateData = object $ symbolData ++ ["upperDeclName" .= toUpperFirst declName]
|
||||
|
||||
let rendered = renderTemplate template templateData
|
||||
logM $ printf "[wasp.scaffold.ts-symbol]: rendered=%s" (show rendered)
|
||||
|
||||
-- NOTE: we modify the file on disk instead of applying an edit through
|
||||
-- the LSP client. See "Current Limitations" above.
|
||||
liftIO $ Text.appendFile (SP.fromAbsFile filepath) rendered
|
||||
|
||||
notifyClientOfFileChanges args
|
||||
respond $ Right Aeson.Null
|
||||
_ -> respond $ Left $ makeInvalidParamsError $ Text.pack $ "Top-level step in path to ext import is not a decl: " ++ show pathToExtImport
|
||||
|
||||
-- Displays a message to the client that a file changed, with a button to
|
||||
-- open the changed file.
|
||||
notifyClientOfFileChanges :: Args -> ServerM ()
|
||||
notifyClientOfFileChanges Args {..} = do
|
||||
let symbol = case symbolName of
|
||||
ExtImportModule _ -> "default export"
|
||||
ExtImportField name -> "export " <> name
|
||||
let message =
|
||||
LSP.ShowMessageRequestParams
|
||||
{ _xtype = LSP.MtInfo,
|
||||
_message = Text.pack $ printf "Created %s in %s." symbol (SP.fromAbsFile filepath),
|
||||
_actions = Just [LSP.MessageActionItem "Open"]
|
||||
}
|
||||
void $
|
||||
LSP.sendRequest LSP.SWindowShowMessageRequest message $ \case
|
||||
Left err -> logM $ "Error showing message for file created: " <> show err
|
||||
Right (Just (LSP.MessageActionItem "Open")) -> do
|
||||
-- Client selected the "Open" button, ask it to display the file.
|
||||
let showDocument =
|
||||
LSP.ShowDocumentParams
|
||||
{ _uri = LSP.filePathToUri $ SP.fromAbsFile filepath,
|
||||
_external = Nothing,
|
||||
_takeFocus = Just True,
|
||||
_selection = Nothing
|
||||
}
|
||||
void $ LSP.sendRequest LSP.SWindowShowDocument showDocument (const (pure ()))
|
||||
Right _ -> return ()
|
||||
|
||||
-- | Check if the scaffold command has a template available for the given args.
|
||||
-- If this is false, running the command with these args will __definitely__
|
||||
-- fail.
|
||||
hasTemplateForArgs :: Args -> Bool
|
||||
hasTemplateForArgs Args {..} = case P.fileExtension $ SP.toPathAbsFile filepath of
|
||||
Nothing -> False
|
||||
Just ext -> isRight $ templateFileFor pathToExtImport ext
|
||||
|
||||
-- | @getTemplateFor pathToExtImport extension@ finds the mustache template in
|
||||
-- @data/lsp/templates/ts@ and compiles it.
|
||||
getTemplateFor :: MonadIO m => ExprPath -> String -> m (Either String Mustache.Template)
|
||||
getTemplateFor exprPath ext = runExceptT $ do
|
||||
templatesDir <- liftIO getTemplatesDir
|
||||
templateFile <- (templatesDir SP.</>) <$> templateFileFor exprPath ext
|
||||
templateExists <- liftIO $ doesFileExist templateFile
|
||||
if templateExists
|
||||
then do
|
||||
compileResult <- liftIO $ Mustache.automaticCompile [SP.fromAbsDir templatesDir] (SP.fromAbsFile templateFile)
|
||||
case compileResult of
|
||||
-- Note: 'error' is used here because all templates should compile succesfully.
|
||||
Left err -> error $ printf "Compilation of template %s failed: %s" (SP.fromAbsFile templateFile) (show err)
|
||||
Right template -> return template
|
||||
else throwError $ printf "No scaffolding template for request: %s does not exist" (SP.fromAbsFile templateFile)
|
||||
|
||||
-- | Renders a mustache template to text.
|
||||
--
|
||||
-- This function is partial: if errors are encountered rendering the template,
|
||||
-- @error@ is returned.
|
||||
renderTemplate :: Mustache.Template -> Aeson.Value -> Text
|
||||
renderTemplate template templateData =
|
||||
let (errs, text) = Mustache.checkedSubstituteValue template $ Mustache.toMustache templateData
|
||||
in if null errs
|
||||
then text
|
||||
else error $ printf "Unexpected errors rendering template: " ++ show errs
|
||||
|
||||
data TemplatesDir
|
||||
|
||||
data Template
|
||||
|
||||
type TemplateFile = SP.Path' (SP.Rel TemplatesDir) (SP.File Template)
|
||||
|
||||
templatesDirInDataDir :: SP.Path' (SP.Rel Wasp.Data.DataDir) (SP.Dir TemplatesDir)
|
||||
templatesDirInDataDir = [SP.reldir|lsp/templates/ts|]
|
||||
|
||||
getTemplatesDir :: IO (SP.Path' SP.Abs (SP.Dir TemplatesDir))
|
||||
getTemplatesDir = (SP.</> templatesDirInDataDir) <$> Wasp.Data.getAbsDataDirPath
|
||||
|
||||
templateFileFor ::
|
||||
MonadError String m =>
|
||||
-- | Path to the external import that the scaffold request came from.
|
||||
ExprPath ->
|
||||
-- | Extension of the file that the request is scaffolding code in.
|
||||
String ->
|
||||
m TemplateFile
|
||||
templateFileFor [Decl "query" _, DictKey "fn"] ".ts" = pure [SP.relfile|query.fn.ts|]
|
||||
templateFileFor [Decl "action" _, DictKey "fn"] ".ts" = pure [SP.relfile|action.fn.ts|]
|
||||
templateFileFor [Decl declType _, DictKey "fn"] ".js"
|
||||
| declType `elem` ["query", "action"] = pure [SP.relfile|operation.fn.js|]
|
||||
templateFileFor [Decl "page" _, DictKey "component"] ext
|
||||
| ext `elem` [".jsx", ".tsx"] = pure [SP.relfile|page.component.jsx|]
|
||||
templateFileFor exprPath ext = throwError $ printf "No template defined for %s with extension %s" (show exprPath) ext
|
@ -3,11 +3,16 @@
|
||||
|
||||
module Wasp.LSP.ExtImport.Path
|
||||
( ExtFileCachePath,
|
||||
cachePathFile,
|
||||
cachePathExtType,
|
||||
WaspStyleExtFilePath (WaspStyleExtFilePath),
|
||||
waspStylePathToCachePath,
|
||||
absPathToCachePath,
|
||||
cachePathToAbsPathWithoutExt,
|
||||
cachePathToAbsPath,
|
||||
tryGetTsconfigForAbsPath,
|
||||
ExtensionType,
|
||||
allowedExts,
|
||||
)
|
||||
where
|
||||
|
||||
@ -23,6 +28,7 @@ import qualified StrongPath.Path as SP
|
||||
import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
|
||||
import Wasp.Project.Common (WaspProjectDir)
|
||||
import Wasp.Util.IO (doesFileExist)
|
||||
import Wasp.Util.StrongPath (stripProperPrefix)
|
||||
|
||||
data ExtensionlessExtFile
|
||||
|
||||
@ -30,8 +36,10 @@ data ExtensionlessExtFile
|
||||
--
|
||||
-- It is stored relative to @src/@ and without an extension so that cache lookups
|
||||
-- (starting with a 'WaspStyleExtFilePath') are more efficient.
|
||||
data ExtFileCachePath
|
||||
= ExtFileCachePath !(SP.Path' (SP.Rel SourceExternalCodeDir) (SP.File ExtensionlessExtFile)) !ExtensionType
|
||||
data ExtFileCachePath = ExtFileCachePath
|
||||
{ cachePathFile :: !(SP.Path' (SP.Rel SourceExternalCodeDir) (SP.File ExtensionlessExtFile)),
|
||||
cachePathExtType :: !ExtensionType
|
||||
}
|
||||
deriving (Show, Eq, Generic)
|
||||
|
||||
-- | Hashes only the path portion (ignoring the extension type). This is so that
|
||||
@ -54,7 +62,7 @@ waspStylePathToCachePath (WaspStyleExtFilePath waspStylePath) = do
|
||||
let (extensionLessFile, extType) = splitExtensionType relFile
|
||||
return $ ExtFileCachePath (SP.fromPathRelFile extensionLessFile) extType
|
||||
|
||||
absPathToCachePath :: LSP.MonadLsp c m => SP.Path' SP.Abs SP.File' -> m (Maybe ExtFileCachePath)
|
||||
absPathToCachePath :: LSP.MonadLsp c m => SP.Path' SP.Abs (SP.File a) -> m (Maybe ExtFileCachePath)
|
||||
absPathToCachePath absFile = do
|
||||
-- Makes the path relative to src/ and deletes the extension.
|
||||
maybeProjectDir <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
|
||||
@ -62,23 +70,28 @@ absPathToCachePath absFile = do
|
||||
Nothing -> pure Nothing
|
||||
Just (projectRootDir :: SP.Path' SP.Abs (SP.Dir WaspProjectDir)) ->
|
||||
let srcDir = projectRootDir SP.</> srcDirInProjectRootDir
|
||||
in case P.stripProperPrefix (SP.toPathAbsDir srcDir) (SP.toPathAbsFile absFile) of
|
||||
in case stripProperPrefix srcDir absFile of
|
||||
Nothing -> pure Nothing
|
||||
Just relFile -> do
|
||||
let (extensionLessFile, extType) = splitExtensionType relFile
|
||||
let (extensionLessFile, extType) = splitExtensionType $ SP.toPathRelFile relFile
|
||||
pure $ Just $ ExtFileCachePath (SP.fromPathRelFile extensionLessFile) extType
|
||||
|
||||
cachePathToAbsPath :: forall m c. LSP.MonadLsp c m => ExtFileCachePath -> m (Maybe (SP.Path' SP.Abs SP.File'))
|
||||
cachePathToAbsPath (ExtFileCachePath cachePath extType) = do
|
||||
-- Converts to an absolute path and finds the appropriate extension.
|
||||
cachePathToAbsPathWithoutExt :: LSP.MonadLsp c m => ExtFileCachePath -> m (Maybe (SP.Path' SP.Abs (SP.File ExtensionlessExtFile)))
|
||||
cachePathToAbsPathWithoutExt (ExtFileCachePath cachePath _) = do
|
||||
-- Converts to an absolute path, but does not add any extension.
|
||||
maybeProjectDir <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
|
||||
case maybeProjectDir of
|
||||
Nothing -> pure Nothing
|
||||
Nothing -> return Nothing
|
||||
Just (projectRootDir :: SP.Path' SP.Abs (SP.Dir WaspProjectDir)) -> do
|
||||
let fileWithNoExtension = projectRootDir SP.</> srcDirInProjectRootDir SP.</> cachePath
|
||||
useFirstExtensionThatExists fileWithNoExtension $ allowedExts extType
|
||||
return $ Just $ projectRootDir SP.</> srcDirInProjectRootDir SP.</> cachePath
|
||||
|
||||
cachePathToAbsPath :: forall m c a. LSP.MonadLsp c m => ExtFileCachePath -> m (Maybe (SP.Path' SP.Abs (SP.File a)))
|
||||
cachePathToAbsPath cp@(ExtFileCachePath _ extType) =
|
||||
cachePathToAbsPathWithoutExt cp >>= \case
|
||||
Nothing -> return Nothing
|
||||
Just absPathWithoutExt -> useFirstExtensionThatExists absPathWithoutExt $ allowedExts extType
|
||||
where
|
||||
useFirstExtensionThatExists :: SP.Path' SP.Abs (SP.File ExtensionlessExtFile) -> [String] -> m (Maybe (SP.Path' SP.Abs SP.File'))
|
||||
useFirstExtensionThatExists :: SP.Path' SP.Abs (SP.File ExtensionlessExtFile) -> [String] -> m (Maybe (SP.Path' SP.Abs (SP.File a)))
|
||||
useFirstExtensionThatExists _ [] = pure Nothing
|
||||
useFirstExtensionThatExists file (ext : exts) =
|
||||
case P.addExtension ext (SP.toPathAbsFile file) of
|
||||
@ -97,10 +110,10 @@ cachePathToAbsPath (ExtFileCachePath cachePath extType) = do
|
||||
-- config files exist.
|
||||
--
|
||||
-- IF the given path is not in either @src/@ subdirectory, returns nothing.
|
||||
tryGetTsconfigForAbsPath :: SP.Path' SP.Abs (SP.Dir WaspProjectDir) -> SP.Path' SP.Abs SP.File' -> Maybe (SP.Path' SP.Abs SP.File')
|
||||
tryGetTsconfigForAbsPath :: SP.Path' SP.Abs (SP.Dir WaspProjectDir) -> SP.Path' SP.Abs (SP.File a) -> Maybe (SP.Path' SP.Abs (SP.File a))
|
||||
tryGetTsconfigForAbsPath projectRootDir file = tsconfigPath [SP.reldir|src/client|] <|> tsconfigPath [SP.reldir|src/server|]
|
||||
where
|
||||
tsconfigPath :: SP.Path' (SP.Rel WaspProjectDir) SP.Dir' -> Maybe (SP.Path' SP.Abs SP.File')
|
||||
tsconfigPath :: SP.Path' (SP.Rel WaspProjectDir) SP.Dir' -> Maybe (SP.Path' SP.Abs (SP.File a))
|
||||
tsconfigPath folder =
|
||||
let absFolder = projectRootDir SP.</> folder
|
||||
in if SP.toPathAbsDir absFolder `P.isProperPrefixOf` SP.toPathAbsFile file
|
||||
|
@ -1,5 +1,6 @@
|
||||
module Wasp.LSP.ExtImport.Syntax
|
||||
( ExtImportNode (..),
|
||||
extImportAtLocation,
|
||||
findExtImportAroundLocation,
|
||||
getAllExtImports,
|
||||
)
|
||||
|
@ -6,9 +6,11 @@ module Wasp.LSP.Handlers
|
||||
didOpenHandler,
|
||||
didChangeHandler,
|
||||
didSaveHandler,
|
||||
executeCommandHandler,
|
||||
completionHandler,
|
||||
signatureHelpHandler,
|
||||
gotoDefinitionHandler,
|
||||
codeActionHandler,
|
||||
)
|
||||
where
|
||||
|
||||
@ -20,6 +22,8 @@ import qualified Language.LSP.Server as LSP
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified Language.LSP.Types.Lens as LSP
|
||||
import Wasp.LSP.Analysis (diagnoseWaspFile)
|
||||
import Wasp.LSP.CodeActions (getCodeActionsInRange)
|
||||
import qualified Wasp.LSP.Commands as Commands
|
||||
import Wasp.LSP.Completion (getCompletionsAtPosition)
|
||||
import Wasp.LSP.DynamicHandlers (registerDynamicCapabilities)
|
||||
import Wasp.LSP.GotoDefinition (gotoDefinitionOfSymbolAtPosition)
|
||||
@ -69,6 +73,9 @@ didSaveHandler :: Handlers ServerM
|
||||
didSaveHandler =
|
||||
LSP.notificationHandler LSP.STextDocumentDidSave $ diagnoseWaspFile . extractUri
|
||||
|
||||
executeCommandHandler :: Handlers ServerM
|
||||
executeCommandHandler = Commands.handleExecuteCommand
|
||||
|
||||
completionHandler :: Handlers ServerM
|
||||
completionHandler =
|
||||
LSP.requestHandler LSP.STextDocumentCompletion $ \request respond -> do
|
||||
@ -90,6 +97,13 @@ signatureHelpHandler =
|
||||
signatureHelp <- handler $ getSignatureHelpAtPosition position
|
||||
respond $ Right signatureHelp
|
||||
|
||||
codeActionHandler :: Handlers ServerM
|
||||
codeActionHandler =
|
||||
LSP.requestHandler LSP.STextDocumentCodeAction $ \request respond -> do
|
||||
let range = request ^. LSP.params . LSP.range
|
||||
codeActions <- handler $ getCodeActionsInRange range
|
||||
respond $ Right $ LSP.List $ map LSP.InR codeActions
|
||||
|
||||
-- | Get the 'Uri' from an object that has a 'TextDocument'.
|
||||
extractUri :: (LSP.HasParams a b, LSP.HasTextDocument b c, LSP.HasUri c LSP.Uri) => a -> LSP.Uri
|
||||
extractUri = (^. (LSP.params . LSP.textDocument . LSP.uri))
|
||||
|
@ -19,6 +19,7 @@ import qualified Language.LSP.Server as LSP
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import System.Exit (ExitCode (ExitFailure), exitWith)
|
||||
import qualified System.Log.Logger
|
||||
import qualified Wasp.LSP.Commands as Commands
|
||||
import Wasp.LSP.Debouncer (newDebouncerIO)
|
||||
import Wasp.LSP.Handlers
|
||||
import Wasp.LSP.Reactor (startReactorThread)
|
||||
@ -38,9 +39,11 @@ lspServerHandlers stopReactor =
|
||||
didOpenHandler,
|
||||
didSaveHandler,
|
||||
didChangeHandler,
|
||||
executeCommandHandler,
|
||||
completionHandler,
|
||||
signatureHelpHandler,
|
||||
gotoDefinitionHandler
|
||||
gotoDefinitionHandler,
|
||||
codeActionHandler
|
||||
]
|
||||
|
||||
serve :: Maybe FilePath -> IO ()
|
||||
@ -76,8 +79,7 @@ serve maybeLogFile = do
|
||||
where
|
||||
runHandler :: ServerM a -> IO a
|
||||
runHandler handler =
|
||||
LSP.runLspT env $ do
|
||||
runRLspM stateTVar handler
|
||||
LSP.runLspT env $ runRLspM stateTVar handler
|
||||
|
||||
exitCode <-
|
||||
LSP.runServer
|
||||
@ -128,7 +130,8 @@ lspServerOptions =
|
||||
{ LSP.textDocumentSync = Just syncOptions,
|
||||
LSP.completionTriggerCharacters = Just [':', ' '],
|
||||
LSP.signatureHelpTriggerCharacters = signatureHelpTriggerCharacters,
|
||||
LSP.signatureHelpRetriggerCharacters = signatureHelpRetriggerCharacters
|
||||
LSP.signatureHelpRetriggerCharacters = signatureHelpRetriggerCharacters,
|
||||
LSP.executeCommandCommands = Just Commands.availableCommands
|
||||
}
|
||||
|
||||
-- | Options to tell the client how to update the server about the state of text
|
||||
|
@ -3,12 +3,14 @@ module Wasp.LSP.Syntax
|
||||
|
||||
-- | Module with utilities for working with/looking for patterns in CSTs
|
||||
lspPositionToOffset,
|
||||
lspRangeToSpan,
|
||||
locationAtOffset,
|
||||
parentIs,
|
||||
hasLeft,
|
||||
isAtExprPlace,
|
||||
lexemeAt,
|
||||
findChild,
|
||||
findAncestor,
|
||||
-- | Printing
|
||||
showNeighborhood,
|
||||
)
|
||||
@ -18,16 +20,30 @@ import Data.List (find, intercalate)
|
||||
import qualified Language.LSP.Types as J
|
||||
import qualified Wasp.Analyzer.Parser.CST as S
|
||||
import Wasp.Analyzer.Parser.CST.Traverse
|
||||
import Wasp.Analyzer.Parser.SourceSpan (SourceSpan (SourceSpan))
|
||||
import Wasp.LSP.Util (allP, anyP)
|
||||
|
||||
-- | @lspPositionToOffset srcString position@ returns 0-based offset from the
|
||||
-- start of @srcString@ to the specified line and column.
|
||||
-- | @lspPositionToOffset srcString position@ converts @position@ into a 0-based
|
||||
-- offset from the start of @srcString@.
|
||||
--
|
||||
-- @position@ is a line/column offset into @srcString@.
|
||||
lspPositionToOffset :: String -> J.Position -> Int
|
||||
lspPositionToOffset srcString (J.Position l c) =
|
||||
let linesBefore = take (fromIntegral l) (lines srcString)
|
||||
in -- We add 1 to the length of each line to make sure to count the newline
|
||||
sum (map ((+ 1) . length) linesBefore) + fromIntegral c
|
||||
|
||||
-- | @lspRangeToSpan srcString range@ converts the @range@ into a 'SourceSpan'.
|
||||
--
|
||||
-- The start and end positions in @range@ are line/column offsets into @srcString@,
|
||||
-- and the returned 'SourceSpan' contains a start and end 0-based offset from the
|
||||
-- start of @srcString@.
|
||||
lspRangeToSpan :: String -> J.Range -> SourceSpan
|
||||
lspRangeToSpan srcString (J.Range start end) =
|
||||
let startOffset = lspPositionToOffset srcString start
|
||||
endOffset = lspPositionToOffset srcString end
|
||||
in SourceSpan startOffset endOffset
|
||||
|
||||
-- | Move to the node containing the offset.
|
||||
--
|
||||
-- If the offset falls on the border between two nodes, it tries to first choose
|
||||
@ -96,6 +112,12 @@ showNeighborhood t =
|
||||
findChild :: S.SyntaxKind -> Traversal -> Maybe Traversal
|
||||
findChild skind t = find ((== skind) . kindAt) $ children t
|
||||
|
||||
findAncestor :: S.SyntaxKind -> Traversal -> Maybe Traversal
|
||||
findAncestor skind t =
|
||||
if kindAt t == skind
|
||||
then Just t
|
||||
else findAncestor skind =<< up t
|
||||
|
||||
-- | @lexeme src traversal@
|
||||
lexemeAt :: String -> Traversal -> String
|
||||
lexemeAt src t = take (widthAt t) $ drop (offsetAt t) src
|
||||
|
@ -1,3 +1,5 @@
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
|
||||
module Wasp.LSP.TypeInference
|
||||
( -- * Inferred types for CST locations
|
||||
inferTypeAtLocation,
|
||||
@ -11,8 +13,11 @@ module Wasp.LSP.TypeInference
|
||||
where
|
||||
|
||||
import Control.Monad (guard)
|
||||
import Data.Aeson (ToJSON)
|
||||
import Data.Aeson.Types (FromJSON)
|
||||
import Data.Foldable (find)
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import GHC.Generics (Generic)
|
||||
import qualified Wasp.Analyzer.Parser.CST as S
|
||||
import Wasp.Analyzer.Parser.CST.Traverse (Traversal)
|
||||
import qualified Wasp.Analyzer.Parser.CST.Traverse as T
|
||||
@ -39,19 +44,23 @@ inferTypeAtLocation src location = findExprPathToLocation src location >>= findT
|
||||
-- }
|
||||
-- @
|
||||
--
|
||||
-- The path to the cursor would be @[Decl "app", DictKey "auth", DictKey "usernameAndPassword"]@.
|
||||
-- The path to the cursor would be @[Decl "app" "todoApp", DictKey "auth", DictKey "usernameAndPassword"]@.
|
||||
type ExprPath = [ExprPathStep]
|
||||
|
||||
data ExprPathStep
|
||||
= -- | @Decl declType@. Enter a declaration of type @declType@.
|
||||
Decl !String
|
||||
= -- | @Decl declType declName@. Enter a declaration of type @declType@ named @declName@.
|
||||
Decl !String !String
|
||||
| -- | @DictKey key@. Enter a dictionary *and* its key @key@.
|
||||
DictKey !String
|
||||
| -- | Enter a value inside a list.
|
||||
List
|
||||
| -- | @Tuple idx@. Enter the @idx@-th value inside of a tuple.
|
||||
Tuple !Int
|
||||
deriving (Eq, Show)
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON ExprPathStep
|
||||
|
||||
instance FromJSON ExprPathStep
|
||||
|
||||
-- | This function only depends on the syntax to the left of the location, and
|
||||
-- tries to be as lenient as possible in finding paths.
|
||||
@ -68,8 +77,10 @@ findExprPathToLocation src location = reverse <$> go location
|
||||
S.Decl -> do
|
||||
typLoc <- find ((== S.DeclType) . T.kindAt) $ T.leftSiblings t
|
||||
let typ = lexemeAt src typLoc
|
||||
nameLoc <- find ((== S.DeclName) . T.kindAt) $ T.leftSiblings t
|
||||
let name = lexemeAt src nameLoc
|
||||
-- Stop recursion after finding a Decl
|
||||
return [Decl typ]
|
||||
return [Decl typ name]
|
||||
S.DictEntry -> case find ((== S.DictKey) . T.kindAt) $ T.leftSiblings t of
|
||||
Just keyLoc -> do
|
||||
-- There is a key to the left, so @t@ is the value for that key.
|
||||
@ -94,7 +105,7 @@ findExprPathToLocation src location = reverse <$> go location
|
||||
-- >>> findTypeForPath [Dict "app", Key "auth", Key "methods", Key "usernameAndPassword"]
|
||||
-- Just (Type.DictType { fields = M.fromList [("configFn", Type.DictOptional { dictEntryType = Type.ExtImportType })] })
|
||||
findTypeForPath :: ExprPath -> Maybe Type
|
||||
findTypeForPath (Decl declType : originalPath) = do
|
||||
findTypeForPath (Decl declType _ : originalPath) = do
|
||||
topType <- getDeclType declType stdTypes
|
||||
go (dtBodyType topType) originalPath
|
||||
where
|
||||
@ -102,7 +113,7 @@ findTypeForPath (Decl declType : originalPath) = do
|
||||
-- @parentType@.
|
||||
go :: Type -> ExprPath -> Maybe Type
|
||||
go typ [] = Just typ
|
||||
go _ (Decl _ : _) = Nothing -- Can't follow a decl in the middle of a path.
|
||||
go _ (Decl _ _ : _) = Nothing -- Can't follow a decl in the middle of a path.
|
||||
go typ (DictKey key : path) =
|
||||
case typ of
|
||||
Type.DictType fields -> do
|
||||
|
@ -4,16 +4,21 @@ module Wasp.LSP.Util
|
||||
hoistMaybe,
|
||||
waspSourceRegionToLspRange,
|
||||
waspPositionToLspPosition,
|
||||
getPathRelativeToProjectDir,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Lens ((+~))
|
||||
import Control.Monad.Trans.Maybe (MaybeT (MaybeT))
|
||||
import Data.Function ((&))
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified Language.LSP.Server as LSP
|
||||
import qualified Language.LSP.Types as LSP hiding (line)
|
||||
import qualified Language.LSP.Types.Lens as LSP
|
||||
import qualified StrongPath as SP
|
||||
import qualified Wasp.Analyzer.Parser as W
|
||||
import qualified Wasp.Analyzer.Parser.SourceRegion as W
|
||||
import Wasp.Project (WaspProjectDir)
|
||||
import Wasp.Util.StrongPath (stripProperPrefix)
|
||||
|
||||
waspSourceRegionToLspRange :: W.SourceRegion -> LSP.Range
|
||||
waspSourceRegionToLspRange rgn =
|
||||
@ -40,3 +45,12 @@ anyP preds x = any ($ x) preds
|
||||
-- | Lift a 'Maybe' into a 'MaybeT' monad transformer.
|
||||
hoistMaybe :: Applicative m => Maybe a -> MaybeT m a
|
||||
hoistMaybe = MaybeT . pure
|
||||
|
||||
-- | @absFileInProjectRootDir file@ finds the path to @file@ if it is inside the
|
||||
-- project root directory.
|
||||
getPathRelativeToProjectDir :: LSP.MonadLsp c m => SP.Path' SP.Abs (SP.File a) -> m (Maybe (SP.Path' (SP.Rel WaspProjectDir) (SP.File a)))
|
||||
getPathRelativeToProjectDir file = do
|
||||
maybeProjectRootDir <- (>>= SP.parseAbsDir) <$> LSP.getRootPath
|
||||
case maybeProjectRootDir of
|
||||
Nothing -> pure Nothing
|
||||
Just projectRootDir -> pure $ stripProperPrefix projectRootDir file
|
||||
|
40
waspc/waspls/test/Wasp/LSP/SyntaxTest.hs
Normal file
40
waspc/waspls/test/Wasp/LSP/SyntaxTest.hs
Normal file
@ -0,0 +1,40 @@
|
||||
module Wasp.LSP.SyntaxTest where
|
||||
|
||||
import qualified Language.LSP.Types as LSP
|
||||
import qualified Language.LSP.Types.Lens as LSP
|
||||
import Test.QuickCheck
|
||||
import Test.Tasty.Hspec
|
||||
import Wasp.Analyzer.Parser.SourceSpan (SourceSpan (SourceSpan))
|
||||
import Wasp.LSP.Syntax
|
||||
|
||||
spec_lspPositionToOffset :: Spec
|
||||
spec_lspPositionToOffset = describe "Wasp.LSP.Syntax.lspPositionToOffset" $ do
|
||||
it "works for 1-line strings" $ do
|
||||
let src = "hello world"
|
||||
let pos = LSP.Position 0 5
|
||||
lspPositionToOffset src pos `shouldBe` 5
|
||||
|
||||
it "works for a multiline string and counts \\n in the offset" $ do
|
||||
let src = "hello\nworld"
|
||||
let pos = LSP.Position 1 2
|
||||
lspPositionToOffset src pos `shouldBe` 8
|
||||
|
||||
it "works for a string with many lines" $ do
|
||||
let srcLines = ["abc", "xyz", "ijk", "lmno", "wasp is the best", "123"]
|
||||
let src = unlines srcLines
|
||||
let pos = LSP.Position 4 3
|
||||
lspPositionToOffset src pos `shouldBe` 20
|
||||
|
||||
spec_lspRangeToSpan :: Spec
|
||||
spec_lspRangeToSpan = describe "Wasp.LSP.Syntax.lspRangeToSpan" $ do
|
||||
it "is the same as running lspPositionToOffset on each endpoint of the range" $ do
|
||||
property $
|
||||
\src
|
||||
(lineStart :: Int)
|
||||
(colStart :: Int)
|
||||
(lineEnd :: Int)
|
||||
(colEnd :: Int) ->
|
||||
let start = LSP.Position (fromIntegral lineStart) (fromIntegral colStart)
|
||||
end = LSP.Position (fromIntegral lineEnd) (fromIntegral colEnd)
|
||||
range = LSP.Range start end
|
||||
in lspRangeToSpan src range == SourceSpan (lspPositionToOffset src start) (lspPositionToOffset src end)
|
Loading…
Reference in New Issue
Block a user