aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.envrc1
-rw-r--r--.gitignore1
-rw-r--r--CHANGELOG.md5
-rw-r--r--LICENSE30
-rw-r--r--README.md13
-rw-r--r--default.nix24
-rw-r--r--nix/sources.json14
-rw-r--r--nix/sources.nix198
-rw-r--r--sh.cabal37
-rw-r--r--shell.nix1
-rw-r--r--src/Process/Shell.hs153
-rw-r--r--test/Main.hs57
12 files changed, 534 insertions, 0 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..1d953f4
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use nix
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8075013
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/dist-newstyle
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3dab14f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for sh
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c90516a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2024, Alexander Foremny
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+ * Neither the name of Alexander Foremny nor the names of other
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0ad027d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+# sh
+
+Conveniently call external processes from Haskell.
+
+## Description
+
+This library makes it convenient to run external processes in your Haskell program.
+
+It offers a single quasi quoter `sh` to run external processes.
+
+The program's exit code, standard output and standard error can be obtained by parameterizing its output type to ``ExitCode``, `(ExitCode, String)`, `(ExitCode, String, String)`, `String`, `(String, String)`, respectively. Instead of `String`, you may chose `ByteString` or `Text`, in both lazy and strict variants, as outputs.
+
+Arguments passed to `sh` are automatically passed to the process in a type-safe way, and appropriately quoted.
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..0545c15
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,24 @@
+{ pkgs ? import (import ./nix/sources.nix).nixpkgs { }
+}:
+let
+ haskellPackages = pkgs.haskellPackages.override
+ {
+ overrides = self: super: {
+ sh = self.callCabal2nix "sh" ./. { };
+ };
+ };
+in
+{
+ shell = haskellPackages.shellFor {
+ packages = (_: [
+ haskellPackages.sh
+ ]);
+ buildInputs = [
+ pkgs.cabal-install
+ pkgs.niv
+ pkgs.ormolu
+ ];
+ withHoogle = true;
+ withHaddock = true;
+ };
+}
diff --git a/nix/sources.json b/nix/sources.json
new file mode 100644
index 0000000..747350b
--- /dev/null
+++ b/nix/sources.json
@@ -0,0 +1,14 @@
+{
+ "nixpkgs": {
+ "branch": "nixos-23.11",
+ "description": "Nix Packages collection",
+ "homepage": null,
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "79baff8812a0d68e24a836df0a364c678089e2c7",
+ "sha256": "1aa16r5rkj2iab37q1wmz4kj2kdcjykqn1p79pbd43wmf6sl34am",
+ "type": "tarball",
+ "url": "https://github.com/NixOS/nixpkgs/archive/79baff8812a0d68e24a836df0a364c678089e2c7.tar.gz",
+ "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
+ }
+}
diff --git a/nix/sources.nix b/nix/sources.nix
new file mode 100644
index 0000000..fe3dadf
--- /dev/null
+++ b/nix/sources.nix
@@ -0,0 +1,198 @@
+# This file has been generated by Niv.
+
+let
+
+ #
+ # The fetchers. fetch_<type> fetches specs of type <type>.
+ #
+
+ fetch_file = pkgs: name: spec:
+ let
+ name' = sanitizeName name + "-src";
+ in
+ if spec.builtin or true then
+ builtins_fetchurl { inherit (spec) url sha256; name = name'; }
+ else
+ pkgs.fetchurl { inherit (spec) url sha256; name = name'; };
+
+ fetch_tarball = pkgs: name: spec:
+ let
+ name' = sanitizeName name + "-src";
+ in
+ if spec.builtin or true then
+ builtins_fetchTarball { name = name'; inherit (spec) url sha256; }
+ else
+ pkgs.fetchzip { name = name'; inherit (spec) url sha256; };
+
+ fetch_git = name: spec:
+ let
+ ref =
+ spec.ref or (
+ if spec ? branch then "refs/heads/${spec.branch}" else
+ if spec ? tag then "refs/tags/${spec.tag}" else
+ abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!"
+ );
+ submodules = spec.submodules or false;
+ submoduleArg =
+ let
+ nixSupportsSubmodules = builtins.compareVersions builtins.nixVersion "2.4" >= 0;
+ emptyArgWithWarning =
+ if submodules
+ then
+ builtins.trace
+ (
+ "The niv input \"${name}\" uses submodules "
+ + "but your nix's (${builtins.nixVersion}) builtins.fetchGit "
+ + "does not support them"
+ )
+ { }
+ else { };
+ in
+ if nixSupportsSubmodules
+ then { inherit submodules; }
+ else emptyArgWithWarning;
+ in
+ builtins.fetchGit
+ ({ url = spec.repo; inherit (spec) rev; inherit ref; } // submoduleArg);
+
+ fetch_local = spec: spec.path;
+
+ fetch_builtin-tarball = name: throw
+ ''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`.
+ $ niv modify ${name} -a type=tarball -a builtin=true'';
+
+ fetch_builtin-url = name: throw
+ ''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`.
+ $ niv modify ${name} -a type=file -a builtin=true'';
+
+ #
+ # Various helpers
+ #
+
+ # https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695
+ sanitizeName = name:
+ (
+ concatMapStrings (s: if builtins.isList s then "-" else s)
+ (
+ builtins.split "[^[:alnum:]+._?=-]+"
+ ((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name)
+ )
+ );
+
+ # The set of packages used when specs are fetched using non-builtins.
+ mkPkgs = sources: system:
+ let
+ sourcesNixpkgs =
+ import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; };
+ hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath;
+ hasThisAsNixpkgsPath = <nixpkgs> == ./.;
+ in
+ if builtins.hasAttr "nixpkgs" sources
+ then sourcesNixpkgs
+ else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then
+ import <nixpkgs> { }
+ else
+ abort
+ ''
+ Please specify either <nixpkgs> (through -I or NIX_PATH=nixpkgs=...) or
+ add a package called "nixpkgs" to your sources.json.
+ '';
+
+ # The actual fetching function.
+ fetch = pkgs: name: spec:
+
+ if ! builtins.hasAttr "type" spec then
+ abort "ERROR: niv spec ${name} does not have a 'type' attribute"
+ else if spec.type == "file" then fetch_file pkgs name spec
+ else if spec.type == "tarball" then fetch_tarball pkgs name spec
+ else if spec.type == "git" then fetch_git name spec
+ else if spec.type == "local" then fetch_local spec
+ else if spec.type == "builtin-tarball" then fetch_builtin-tarball name
+ else if spec.type == "builtin-url" then fetch_builtin-url name
+ else
+ abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}";
+
+ # If the environment variable NIV_OVERRIDE_${name} is set, then use
+ # the path directly as opposed to the fetched source.
+ replace = name: drv:
+ let
+ saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name;
+ ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}";
+ in
+ if ersatz == "" then drv else
+ # this turns the string into an actual Nix path (for both absolute and
+ # relative paths)
+ if builtins.substring 0 1 ersatz == "/" then /. + ersatz else /. + builtins.getEnv "PWD" + "/${ersatz}";
+
+ # Ports of functions for older nix versions
+
+ # a Nix version of mapAttrs if the built-in doesn't exist
+ mapAttrs = builtins.mapAttrs or (
+ f: set: with builtins;
+ listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set))
+ );
+
+ # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295
+ range = first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1);
+
+ # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257
+ stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1));
+
+ # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269
+ stringAsChars = f: s: concatStrings (map f (stringToCharacters s));
+ concatMapStrings = f: list: concatStrings (map f list);
+ concatStrings = builtins.concatStringsSep "";
+
+ # https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331
+ optionalAttrs = cond: as: if cond then as else { };
+
+ # fetchTarball version that is compatible between all the versions of Nix
+ builtins_fetchTarball = { url, name ? null, sha256 }@attrs:
+ let
+ inherit (builtins) lessThan nixVersion fetchTarball;
+ in
+ if lessThan nixVersion "1.12" then
+ fetchTarball ({ inherit url; } // (optionalAttrs (name != null) { inherit name; }))
+ else
+ fetchTarball attrs;
+
+ # fetchurl version that is compatible between all the versions of Nix
+ builtins_fetchurl = { url, name ? null, sha256 }@attrs:
+ let
+ inherit (builtins) lessThan nixVersion fetchurl;
+ in
+ if lessThan nixVersion "1.12" then
+ fetchurl ({ inherit url; } // (optionalAttrs (name != null) { inherit name; }))
+ else
+ fetchurl attrs;
+
+ # Create the final "sources" from the config
+ mkSources = config:
+ mapAttrs
+ (
+ name: spec:
+ if builtins.hasAttr "outPath" spec
+ then
+ abort
+ "The values in sources.json should not have an 'outPath' attribute"
+ else
+ spec // { outPath = replace name (fetch config.pkgs name spec); }
+ )
+ config.sources;
+
+ # The "config" used by the fetchers
+ mkConfig =
+ { sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null
+ , sources ? if sourcesFile == null then { } else builtins.fromJSON (builtins.readFile sourcesFile)
+ , system ? builtins.currentSystem
+ , pkgs ? mkPkgs sources system
+ }: rec {
+ # The sources, i.e. the attribute set of spec name to spec
+ inherit sources;
+
+ # The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers
+ inherit pkgs;
+ };
+
+in
+mkSources (mkConfig { }) // { __functor = _: settings: mkSources (mkConfig settings); }
diff --git a/sh.cabal b/sh.cabal
new file mode 100644
index 0000000..4948851
--- /dev/null
+++ b/sh.cabal
@@ -0,0 +1,37 @@
+cabal-version: 3.4
+name: sh
+version: 0.1.0.0
+license: BSD-3-Clause
+license-file: LICENSE
+maintainer: aforemny@posteo.de
+author: Alexander Foremny
+
+build-type: Simple
+extra-doc-files: CHANGELOG.md
+
+library
+ exposed-modules: Process.Shell
+ hs-source-dirs: src
+ default-language: GHC2021
+ ghc-options: -Wall
+ build-depends:
+ base,
+ bytestring,
+ megaparsec,
+ template-haskell,
+ text,
+ typed-process,
+ utf8-string
+
+test-suite sh-test
+ type: exitcode-stdio-1.0
+ main-is: Main.hs
+ hs-source-dirs: test
+ default-language: GHC2021
+ ghc-options: -Wall
+ build-depends:
+ base,
+ bytestring,
+ hspec,
+ sh,
+ text
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..a6bdf20
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1 @@
+(import ./. { }).shell
diff --git a/src/Process/Shell.hs b/src/Process/Shell.hs
new file mode 100644
index 0000000..94ba3c9
--- /dev/null
+++ b/src/Process/Shell.hs
@@ -0,0 +1,153 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DefaultSignatures #-}
+{-# LANGUAGE OverlappingInstances #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+module Process.Shell
+ ( sh,
+ Quotable (..),
+ )
+where
+
+import Control.Monad
+import Data.ByteString qualified as B
+import Data.ByteString.Lazy qualified as LB
+import Data.ByteString.Lazy.UTF8 qualified as LB
+import Data.ByteString.UTF8 qualified as B
+import Data.Functor.Identity
+import Data.Maybe
+import Data.String
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as T
+import Data.Text.Lazy qualified as LT
+import Data.Text.Lazy.Encoding qualified as LT
+import Data.Void
+import Language.Haskell.TH
+import Language.Haskell.TH.Quote
+import System.Process.Typed
+import Text.Megaparsec
+import Text.Megaparsec.Char
+
+class Processable a r where
+ sh_ :: a -> r
+
+instance (Processable a r) => Processable (String -> a) (String -> r) where
+ sh_ f x = sh_ (f x)
+
+instance Processable String (IO ()) where
+ sh_ s = do
+ runProcess_ (fromString s)
+
+instance (Outputable a) => Processable String (IO a) where
+ sh_ s = do
+ fromLBS <$> readProcessInterleaved_ (fromString s)
+
+instance (Outputable stdout, Outputable stderr) => Processable String (IO (stdout, stderr)) where
+ sh_ s = do
+ (\(out, err) -> (fromLBS out, fromLBS err)) <$> readProcess_ (fromString s)
+
+instance (Outputable stdout, Outputable stderr) => Processable String (IO (ExitCode, stdout, stderr)) where
+ sh_ s = do
+ (\(exitCode, out, err) -> (exitCode, fromLBS out, fromLBS err)) <$> readProcess (fromString s)
+
+class Outputable a where
+ fromLBS :: LB.ByteString -> a
+
+instance Outputable String where
+ fromLBS = LB.toString
+
+instance Outputable B.ByteString where
+ fromLBS = LB.toStrict
+
+instance Outputable LB.ByteString where
+ fromLBS = id
+
+instance Outputable T.Text where
+ fromLBS = T.decodeUtf8 . fromLBS
+
+instance Outputable LT.Text where
+ fromLBS = LT.decodeUtf8
+
+class Quotable a where
+ squote :: a -> String
+ squote = squote . toString
+
+ dquote :: a -> String
+ dquote = dquote . toString
+
+ toString :: a -> String
+ default toString :: (Show a) => a -> String
+ toString = show
+
+instance Quotable String where
+ squote s = "'" <> quote' s <> "'"
+ where
+ quote' [] = []
+ quote' ('\'' : cs) = '\'' : '\\' : '\'' : '\'' : quote' cs
+ quote' (c : cs) = c : quote' cs
+
+ dquote s = "\"" <> quote' s <> "\""
+ where
+ quote' [] = []
+ quote' ('\\' : c : cs) = c : quote' cs
+ quote' ('"' : cs) = '\\' : '"' : quote' cs
+ quote' (c : cs) = c : quote' cs
+
+instance Quotable Int
+
+instance Quotable B.ByteString where
+ toString = B.toString
+
+instance Quotable LB.ByteString where
+ toString = LB.toString
+
+instance Quotable T.Text where
+ toString = T.unpack
+
+instance Quotable LT.Text where
+ toString = LT.unpack
+
+data Expr a
+ = Lit String
+ | Var Bool a
+ deriving (Show)
+
+expr :: (String -> b) -> (Bool -> a -> b) -> Expr a -> b
+expr f _ (Lit a) = f a
+expr _ g (Var q a) = g q a
+
+unVar :: Expr a -> Maybe a
+unVar (Lit _) = Nothing
+unVar (Var _ a) = Just a
+
+sh :: QuasiQuoter
+sh = QuasiQuoter quoteExp undefined undefined undefined
+ where
+ quoteExp :: String -> Q Exp
+ quoteExp =
+ either (fail . errorBundlePretty) makeExp
+ . parse (parser <* eof) ""
+
+ parser :: ParsecT Void String Identity [Expr ()]
+ parser =
+ many . choice $
+ [ const (Lit "%") <$> string "%%",
+ const (Var True ()) <$> string "'%'",
+ const (Var False ()) <$> satisfy (== '%'),
+ Lit <$> takeWhile1P Nothing (not . (`elem` "'%")),
+ Lit <$> string "'%",
+ Lit <$> string "'"
+ ]
+
+ makeExp :: [Expr ()] -> Q Exp
+ makeExp exprs' = do
+ exprs <-
+ mapM
+ (expr (pure . Lit) (\q _ -> Var q <$> newName "x"))
+ exprs'
+ lamE (map varP (mapMaybe unVar exprs)) . appE [|sh_|] $
+ flip (foldM (flip go)) exprs =<< [|""|]
+
+ go (Lit s) = appE [|flip (++) s|] . pure
+ go (Var q n) = appE (appE [|flip (++)|] (appE [|if q then squote else dquote|] (varE n))) . pure
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
index 0000000..ad69469
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,57 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Main (main) where
+
+import Data.ByteString.Char8 qualified as B
+import Data.ByteString.Lazy.Char8 qualified as LB
+import Data.Text qualified as T
+import Data.Text.Lazy qualified as LT
+import Process.Shell
+import Test.Hspec
+
+main :: IO ()
+main = hspec do
+ describe "output" do
+ it "capture stdout" do
+ (`shouldBe` "stdout") . fst @String @String =<< [sh|echo -n stdout|]
+ it "capture stderr" do
+ (`shouldBe` "stderr") . snd @String =<< [sh|>&2 echo -n stderr|]
+ it "capture stdout and stderr interleaved" do
+ (`shouldBe` ("stdout", "stderr"))
+ =<< [sh|
+ echo -n stdout
+ >&2 echo -n stderr
+ |]
+ it "capture interleaved" do
+ (`shouldBe` "stdout\nstderr\n")
+ =<< [sh|
+ echo stdout
+ >&2 echo stderr
+ |]
+ describe "arguments" do
+ it "passes `Int`" do
+ (`shouldBe` "1") =<< [sh|echo -n %|] (1 :: Int)
+ it "passes `Text`" do
+ (`shouldBe` "foobar") =<< [sh|echo -n %|] (T.pack "foobar")
+ (`shouldBe` "foobar") =<< [sh|echo -n %|] (LT.pack "foobar")
+ it "passes `ByteString`" do
+ (`shouldBe` "foobar") =<< [sh|echo -n %|] (B.pack "foobar")
+ (`shouldBe` "foobar") =<< [sh|echo -n %|] (LB.pack "foobar")
+ describe "quoting" do
+ it "preserves arguments" do
+ (`shouldBe` "foo\\ bar")
+ =<< [sh|printf %%q %|] "foo bar"
+ it "preserves special characters" do
+ (`shouldBe` "foo\\ bar")
+ =<< [sh|foo=foo; bar=bar; ( printf %%q % )|] "$foo $bar"
+ it "escapes special characters" do
+ (`shouldBe` "\\$foo\\ \\$bar")
+ =<< [sh|printf %%q '%'|] "$foo $bar"
+ it "preserves empty arguments" do
+ (`shouldBe` "''") =<< [sh|printf %%q %|] ""
+ (`shouldBe` "''") =<< [sh|printf %%q '%'|] ""
+ describe "parsing" do
+ it "parses garbled arguments" do
+ (`shouldBe` "% foo") =<< [sh|echo -n '% ' %|] "foo"
+ (`shouldBe` " foo") =<< [sh|echo -n ' ' %|] "foo"