diff --git a/.gitignore b/.gitignore index 5e1fd31..e17f215 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /.purs* /.psa* /.spago/ +/dist/ diff --git a/.travis.yml b/.travis.yml index f61a120..8103279 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,24 +19,31 @@ install: - tar -xvf $HOME/purescript.tar.gz -C $HOME/ - chmod a+x $HOME/purescript - npm install -g spago +- npm install - spago install script: - spago build - spago test - spago docs -- spago bundle-app -m Docs.Search.App --to docs-search-app.js -- spago bundle-app -m Docs.Search.IndexBuilder --to index-builder.js -- node index-builder.js +- npm run build +- node dist/main.js build-index deploy: - provider: releases api_key: $API_KEY file: - - docs-search-app.js - - index-builder.js + - dist/docs-search-app.js + - dist/main.js skip_cleanup: true on: tags: true script: - echo 'done' + - provider: npm + api_key: $NPM_API_KEY + email: klntsky@gmail.com + skip_cleanup: true + on: + tags: true + branch: master diff --git a/README.md b/README.md index 7d24981..eb6a4b5 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,34 @@ An app that adds search capabilities to generated documentation for purescript code. -The goal is to replicate all functionality of pursuit, including querying by type. +It supports nearly-all functionality of [Pursuit](https://github.com/purescript/pursuit), including querying by type. -See [#89](https://github.com/spacchetti/spago/issues/89). +## Installing -To see it in action, run the following: +Run `npm install purescript-docs-search`. -``` -spago build -spago docs -spago bundle-app -m Docs.Search.App --to generated-docs/docs-search-app.js -spago run -m Docs.Search.IndexBuilder -``` +## Usage -## UI +There are two usage scenarios: + +### Patching static documentation + +Use `purescript-docs-search build-index` command to patch HTML files located in `generated-docs/html`. You then will be able to search for declarations or types: + +![Preview](preview.png) The user interface of the app is optimised for keyboard-only use. **S** hotkey can be used to focus on the search field, **Escape** can be used to leave it. Pressing **Escape** twice will close the search results listing. + +### Using the CLI + +Running `purescript-docs-search` within a project directory will open an interactive command-line session. + +Note that unlike in Pursuit, most relevant results will appear last. + +A quick demo: + +[![asciicast](https://asciinema.org/a/Hexie5JoWjlAqLqv2IgafIdb9.svg)](https://asciinema.org/a/Hexie5JoWjlAqLqv2IgafIdb9) + +You may notice that the CLI offers slightly better results than the web interface. This is a performance tradeoff. diff --git a/package.json b/package.json new file mode 100644 index 0000000..a495f69 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "purescript-docs-search", + "version": "0.0.1", + "description": "Search frontend for the documentation generated by the PureScript compiler.", + "main": "dist/main.js", + "directories": { + "test": "test" + }, + "bin": { + "purescript-docs-search": "dist/main.js" + }, + "files": [ + "dist/main.js", + "dist/docs-search-app.js", + "README.md" + ], + "scripts": { + "test": "spago test", + "bundle-app": "spago bundle-app -m Docs.Search.App --to dist/docs-search-app.js", + "bundle-main": "spago bundle-app -m Docs.Search.Main --to dist/main.js && browserify --no-builtins --no-commondir --no-detect-globals --node dist/main.js --outfile dist/main-bundled.js && echo \"#!/usr/bin/env node\" > dist/main.js && cat dist/main-bundled.js >> dist/main.js && rm dist/main-bundled.js", + "build": "npm run bundle-app && npm run bundle-main", + "clean": "rm -rf dist" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/spacchetti/purescript-docs-search.git" + }, + "keywords": [ + "purescript" + ], + "author": "Kalnitsky Vladimir ", + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/spacchetti/purescript-docs-search/issues" + }, + "homepage": "https://github.com/spacchetti/purescript-docs-search#readme", + "dependencies": {}, + "devDependencies": { + "browserify": "^16.3.0", + "glob": "^7.1.4", + "spago": "^0.8.5" + } +} diff --git a/packages.dhall b/packages.dhall index c5188a1..6d40ce1 100644 --- a/packages.dhall +++ b/packages.dhall @@ -47,6 +47,28 @@ let additions = [ "css", "halogen" ] "https://github.com/slamdata/purescript-halogen-css.git" "v8.0.0" + , optparse = + mkPackage + [ "prelude" + , "effect" + , "exitcodes" + , "strings" + , "ordered-collections" + , "arrays" + , "console" + , "memoize" + , "transformers" + , "exists" + , "node-process" + , "free" + ] + "https://github.com/f-o-a-m/purescript-optparse.git" + "v3.0.1" + , exitcodes = + mkPackage + [ "enums" ] + "https://github.com/Risto-Stevcev/purescript-exitcodes.git" + "v4.0.0" } in upstream ⫽ overrides ⫽ additions diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..1070223 Binary files /dev/null and b/preview.png differ diff --git a/spago.dhall b/spago.dhall index 643f9e5..72bee33 100644 --- a/spago.dhall +++ b/spago.dhall @@ -21,6 +21,8 @@ , "node-fs" , "node-fs-aff" , "node-process" + , "node-readline" + , "optparse" , "profunctor" , "search-trie" , "string-parsers" diff --git a/src/Docs/Search/App/SearchResults.purs b/src/Docs/Search/App/SearchResults.purs index d5df217..04c9221 100644 --- a/src/Docs/Search/App/SearchResults.purs +++ b/src/Docs/Search/App/SearchResults.purs @@ -7,24 +7,19 @@ import Docs.Search.Config (config) import Docs.Search.Declarations (DeclLevel(..), declLevelToHashAnchor) import Docs.Search.DocsJson (DataDeclType(..)) import Docs.Search.Extra ((>#>)) -import Docs.Search.Index (Index) -import Docs.Search.Index as Index -import Docs.Search.SearchResult (ResultInfo(..), SearchResult, typeOf) +import Docs.Search.SearchResult (ResultInfo(..), SearchResult) import Docs.Search.TypeDecoder (Constraint(..), FunDep(..), FunDeps(..), Kind(..), QualifiedName(..), Type(..), TypeArgument(..), joinForAlls, joinRows) -import Docs.Search.TypeIndex (TypeIndex) -import Docs.Search.TypeIndex as TypeIndex -import Docs.Search.TypeQuery (TypeQuery(..), parseTypeQuery, penalty) +import Docs.Search.Engine as SearchEngine +import Docs.Search.Engine (ResultsType(..)) import CSS (textWhitespace, whitespacePreWrap) import Data.Array ((!!)) import Data.Array as Array -import Data.Either (hush) import Data.List as List -import Data.Maybe (Maybe(..), isJust, maybe) +import Data.Maybe (Maybe(..), isJust) import Data.Newtype (unwrap, wrap) -import Data.String (length) as String import Data.String.CodeUnits (stripSuffix) as String -import Data.String.Common (toLower, trim) as String +import Data.String.Common (null, trim) as String import Data.String.Pattern (Pattern(..)) as String import Effect.Aff (Aff) import Halogen as H @@ -38,15 +33,11 @@ import Web.HTML as HTML import Web.HTML.Location as Location import Web.HTML.Window as Window -data Mode = Off | Loading | Active | InputTooShort +data Mode = Off | Loading | Active derive instance eqMode :: Eq Mode --- | Is it a search by type or by name? -data ResultsType = TypeResults TypeQuery | DeclResults - -type State = { index :: Index - , typeIndex :: TypeIndex +type State = { searchEngineState :: SearchEngine.State , results :: Array SearchResult , resultsType :: ResultsType , input :: String @@ -68,8 +59,7 @@ mkComponent -> H.Component HH.HTML Query i o Aff mkComponent contents = H.mkComponent - { initialState: const { index: mempty - , typeIndex: mempty + { initialState: const { searchEngineState: mempty , results: [] , resultsType: DeclResults , input: "" @@ -99,36 +89,20 @@ handleQuery (MessageFromSearchField (InputUpdated input_) next) = do state <- H.modify (_ { input = input }) - if String.length input < 2 + if String.null input then do - if input == "" - then do H.modify_ (_ { mode = Off }) showPageContents - else do - H.modify_ (_ { mode = InputTooShort }) - hidePageContents else do H.modify_ (_ { mode = Loading, resultsCount = config.resultsCount }) void $ H.fork do - let resultsType = - maybe DeclResults TypeResults (hush (parseTypeQuery state.input) - >>= isValuableTypeQuery) - - case resultsType of - - DeclResults -> do - { index, results } <- H.liftAff $ Index.query state.index (String.toLower state.input) - H.modify_ (_ { results = results - , mode = Active - , index = index }) - - TypeResults query -> do - { index, results } <- H.liftAff $ TypeIndex.query state.typeIndex query - H.modify_ (_ { results = sortByDistance query results - , mode = Active - , typeIndex = index }) + { searchEngineState, results, resultsType } <- H.liftAff $ + SearchEngine.query state.searchEngineState state.input + H.modify_ (_ { results = results + , mode = Active + , searchEngineState = searchEngineState + , resultsType = resultsType }) hidePageContents @@ -181,12 +155,6 @@ render { mode: Off } = HH.div_ [] render { mode: Loading } = renderContainer $ [ HH.h1_ [ HH.text "Loading..." ] ] -render { mode: InputTooShort } = - renderContainer $ - [ HH.h1_ [ HH.text "Error" ] ] <> - [ HH.div [ HP.classes [ wrap "result", wrap "result--empty" ] ] - [ HH.text "Search query is too short." ] - ] render state@{ mode: Active, results: [] } = renderContainer $ @@ -299,11 +267,7 @@ renderResultType renderResultType result = case result.info of ValueResult { type: ty } -> - wrapSignature [ HH.a [ makeHref ValueLevel false result.moduleName result.name - , HE.onClick $ const $ Just $ SearchResultClicked result.moduleName ] - [ HH.text result.name ] - , HH.text " :: " - , renderType ty ] + wrapSignature $ renderValueSignature result ty TypeClassResult info -> wrapSignature $ renderTypeClassSignature info result @@ -321,6 +285,21 @@ renderResultType result = wrapSignature signature = [ HH.pre [ HP.class_ (wrap "result__signature") ] [ HH.code_ signature ] ] +renderValueSignature + :: forall a rest + . { moduleName :: String + , name :: String + | rest + } + -> Type + -> Array (HH.HTML a Action) +renderValueSignature result ty = + [ HH.a [ makeHref ValueLevel false result.moduleName result.name + , HE.onClick $ const $ Just $ SearchResultClicked result.moduleName ] + [ HH.text result.name ] + , HH.text " :: " + , renderType ty ] + renderTypeClassSignature :: forall a rest . { fundeps :: FunDeps @@ -582,8 +561,8 @@ renderQualifiedName isInfix level (QualifiedName { moduleName, name }) renderKind :: forall a - . Kind -> - HH.HTML a Action + . Kind + -> HH.HTML a Action renderKind = case _ of Row k1 -> HH.span_ [ HH.text "# ", renderKind k1 ] FunKind k1 k2 -> HH.span_ [ renderKind k1, syntax " -> ", renderKind k2 ] @@ -616,17 +595,3 @@ syntax str = HH.span [ HP.class_ (wrap "syntax") ] [ HH.text str ] space :: forall a b. HH.HTML a b space = HH.text " " - -isValuableTypeQuery :: TypeQuery -> Maybe TypeQuery -isValuableTypeQuery (QVar _) = Nothing -isValuableTypeQuery (QConst _) = Nothing -isValuableTypeQuery query = Just query - -sortByDistance :: TypeQuery -> Array SearchResult -> Array SearchResult -sortByDistance typeQuery results = - _.result <$> Array.sortBy comparePenalties resultsWithPenalties - where - comparePenalties r1 r2 = compare r1.penalty r2.penalty - resultsWithPenalties = results <#> - \result -> { penalty: typeOf (unwrap result).info <#> penalty typeQuery - , result } diff --git a/src/Docs/Search/Config.purs b/src/Docs/Search/Config.purs index 36fa956..ad7ea39 100644 --- a/src/Docs/Search/Config.purs +++ b/src/Docs/Search/Config.purs @@ -17,19 +17,20 @@ config = , numberOfIndexParts: 50 -- ^ In how many parts the index should be splitted? , mkIndexPartPath: - \(partId :: Int) -> "generated-docs/index/declarations/" <> show partId <> ".js" + \(partId :: Int) -> "/index/declarations/" <> show partId <> ".js" , mkIndexPartLoadPath: \(partId :: Int) -> "../index/declarations/" <> show partId <> ".js" , resultsCount: 25 -- ^ How many results to show by default? - , penalties: { typeVars: 6 + , penalties: { typeVars: 2 , match: 2 , matchConstraint: 1 - , instantiate: 1 - , generalize: 4 - , rowsMismatch: 6 - , mismatch: 10 + , instantiate: 2 + , generalize: 2 + , rowsMismatch: 3 , missingConstraint: 1 , excessiveConstraint: 1 } + -- ^ Penalties used to determine how "far" a type query is from a given type. + -- See Docs.Search.TypeQuery } diff --git a/src/Docs/Search/Declarations.purs b/src/Docs/Search/Declarations.purs index 1f11efd..843a918 100644 --- a/src/Docs/Search/Declarations.purs +++ b/src/Docs/Search/Declarations.purs @@ -8,6 +8,7 @@ import Docs.Search.TypeDecoder (Constraint(..), QualifiedName(..), Type(..), joi import Control.Alt ((<|>)) import Data.Array ((!!)) +import Data.Array as Array import Data.Foldable (foldr) import Data.List (List, (:)) import Data.List as List @@ -15,8 +16,8 @@ import Data.Maybe (Maybe(..), fromMaybe) import Data.Newtype (class Newtype, unwrap, wrap) import Data.Search.Trie (Trie, alter) import Data.String.CodeUnits (stripPrefix, stripSuffix, toCharArray) -import Data.String.Common (toLower) import Data.String.Common (split) as String +import Data.String.Common (toLower) import Data.String.Pattern (Pattern(..)) type ModuleName = String @@ -178,11 +179,18 @@ extractPackageName name = let chunks = String.split (Pattern "/") name in fromMaybe "" $ chunks !! 0 >>= \dir -> - -- TODO: is it safe to assume that directory name is ".spago"? if dir == ".spago" then chunks !! 1 else - Just "" + let + bowerComponentsIndex = + Array.findIndex (_ == "bower_components") chunks + in + case bowerComponentsIndex of + Just n -> + chunks !! (n + 1) + Nothing -> + Just "" resultsForChildDeclaration :: PackageName diff --git a/src/Docs/Search/DocsJson.purs b/src/Docs/Search/DocsJson.purs index 69156af..a98d91f 100644 --- a/src/Docs/Search/DocsJson.purs +++ b/src/Docs/Search/DocsJson.purs @@ -2,10 +2,9 @@ module Docs.Search.DocsJson where import Prelude -import Docs.Search.TypeDecoder +import Docs.Search.TypeDecoder (Constraint, FunDeps, Kind, Type, TypeArgument) -import Control.Promise (Promise, toAffE) -import Data.Argonaut.Core (Json, fromString, stringify, toString) +import Data.Argonaut.Core (fromString, stringify, toString) import Data.Argonaut.Decode (class DecodeJson, decodeJson, (.:), (.:?)) import Data.Argonaut.Encode (class EncodeJson, encodeJson) import Data.Either (Either(..)) @@ -13,8 +12,6 @@ import Data.Generic.Rep (class Generic) import Data.Generic.Rep.Show (genericShow) import Data.Maybe (Maybe(..)) import Data.Newtype (class Newtype, unwrap) -import Effect (Effect) -import Effect.Aff (Aff) newtype DocsJson = DocsJson { name :: String diff --git a/src/Docs/Search/Engine.purs b/src/Docs/Search/Engine.purs new file mode 100644 index 0000000..93fba11 --- /dev/null +++ b/src/Docs/Search/Engine.purs @@ -0,0 +1,59 @@ +module Docs.Search.Engine where + +import Prelude + +import Docs.Search.TypeQuery (TypeQuery(..), parseTypeQuery, penalty) +import Docs.Search.SearchResult (SearchResult, typeOf) +import Docs.Search.Index as Index +import Docs.Search.Index (Index) +import Docs.Search.TypeIndex as TypeIndex +import Docs.Search.TypeIndex (TypeIndex) + +import Data.Array as Array +import Data.Either (hush) +import Data.Maybe (Maybe(..)) +import Data.Newtype (unwrap) +import Data.String.Common as String +import Effect.Aff (Aff) + +data ResultsType = TypeResults TypeQuery | DeclResults + +type State = { index :: Index + , typeIndex :: TypeIndex + } + +query + :: State + -> String + -> Aff { searchEngineState :: State + , results :: Array SearchResult + , resultsType :: ResultsType + } +query { index, typeIndex } input = + case hush (parseTypeQuery input) >>= isValuableTypeQuery of + Nothing -> do + response <- Index.query index (String.toLower input) + pure { searchEngineState: { index: response.index, typeIndex } + , results: response.results + , resultsType: DeclResults } + + Just typeQuery -> do + response <- TypeIndex.query typeIndex typeQuery + pure { searchEngineState: { index, typeIndex: response.typeIndex } + , results: sortByDistance typeQuery response.results + , resultsType: TypeResults typeQuery } + +isValuableTypeQuery :: TypeQuery -> Maybe TypeQuery +isValuableTypeQuery (QVar _) = Nothing +isValuableTypeQuery (QConst _) = Nothing +isValuableTypeQuery other = Just other + +sortByDistance :: TypeQuery -> Array SearchResult -> Array SearchResult +sortByDistance typeQuery results = + _.result <$> Array.sortBy comparePenalties resultsWithPenalties + where + comparePenalties r1 r2 = compare r1.penalty r2.penalty + resultsWithPenalties = + results <#> + \result -> { penalty: typeOf (unwrap result).info <#> penalty typeQuery + , result } diff --git a/src/Docs/Search/Extra.js b/src/Docs/Search/Extra.js new file mode 100644 index 0000000..5d32f33 --- /dev/null +++ b/src/Docs/Search/Extra.js @@ -0,0 +1,9 @@ +/* global exports require */ + +var glob = require('glob'); + +exports.glob = function (pattern) { + return function () { + return glob.sync(pattern); + }; +}; diff --git a/src/Docs/Search/Extra.purs b/src/Docs/Search/Extra.purs index d06f0ef..7027fff 100644 --- a/src/Docs/Search/Extra.purs +++ b/src/Docs/Search/Extra.purs @@ -2,8 +2,12 @@ module Docs.Search.Extra where import Prelude -import Data.Foldable (class Foldable, foldMap) +import Data.Foldable (class Foldable, foldMap, foldl) +import Data.List.NonEmpty (NonEmptyList, cons', uncons) import Data.Maybe (Maybe(..)) +import Effect (Effect) +import Data.List as List +import Data.List ((:)) whenJust :: forall a m. Monad m => Maybe a -> (a -> m Unit) -> m Unit whenJust (Just a) f = f a @@ -13,3 +17,19 @@ foldMapFlipped :: forall a m f. Foldable f => Monoid m => f a -> (a -> m) -> m foldMapFlipped = flip foldMap infixr 7 foldMapFlipped as >#> + +foreign import glob :: String -> Effect (Array String) + +foldl1 :: forall a. (a -> a -> a) -> NonEmptyList a -> a +foldl1 f as = + case uncons as of + { head, tail } -> foldl f head tail + +foldr1 :: forall a. (a -> a -> a) -> NonEmptyList a -> a +foldr1 f = go List.Nil + where + go acc x = case uncons x of + { head, tail } -> case List.uncons tail of + Nothing -> List.foldl (flip f) head acc + Just { head: head1, tail: tail1 } -> + go (head : acc) (cons' head1 tail1) diff --git a/src/Docs/Search/Index.purs b/src/Docs/Search/Index.purs index 1975395..df384a4 100644 --- a/src/Docs/Search/Index.purs +++ b/src/Docs/Search/Index.purs @@ -98,6 +98,7 @@ getPartId (a : _) = Char.toCharCode a `mod` config.numberOfIndexParts getPartId _ = 0 +-- | Load a part of the index by injecting a