Skip to content

Commit

Permalink
feat!: read shmuxfiles from parent folders and support shebang in scr…
Browse files Browse the repository at this point in the history
…ipts (#5)

## Breaking Change

Default _shmuxfile_ is now any `shmuxfile.*` in the current or parent
folders. It used to be `shmux.sh`, but the documentation always
referenced _shmuxfiles_ and I figured this way it was easier to follow.

## Features

* Configuration can now be read from any parent folder (no mode `cd ..`
before running `shmux`)
* Scripts can now declare a `#!` directive which will be honored when
running the scripts

## Issues
- Closes #3 
- Closes #1
  • Loading branch information
shikaan authored Dec 31, 2022
2 parents a445237 + 1d9eeaa commit 5824018
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 70 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Test

on:
pull_request:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- run: go test ./...
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
.build
shmux.*
.DS_Store
.DS_Store
shmux-dev
21 changes: 8 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ Head to the [releases](https://github.com/shikaan/shmux/releases) page and downl

### Usage

A common use case for `shmux` is running simple scripts for your app in a standardised and language agnostic way. These scripts are to be found in the _configuraiton_ file, also known as _shmuxfile_.
A common use case for `shmux` is running simple scripts in a standardized and language-agnostic way. These scripts are to be found in the _configuration_ file, also known as _shmuxfile_.

For exmaple, a `shmux.sh` for a Go project might look like:
For example, a `shmuxfile.sh` for a Go project might look like:

```sh
build:
Expand All @@ -41,7 +41,7 @@ greet:
echo "Hello $1, my old friend"
```

Which can then be utilized from within the same folder as
Which can then be utilized as

```bash
# Runs the test command
Expand All @@ -57,10 +57,12 @@ $ shmux greet -- "darkness"

### More Usage

What if we wanted to write the scripts in JavaScript? Well, you then just need a `shmux.js` which reads something like
What if we wanted to write the scripts in JavaScript? Well, you then just need a `shmuxfile.js` with a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) defining the interpreter to be used and you're set.

```js
greet:
#!/usr/bin/env node

const friend = "$1"
const author = "$@"
const message = friend === "darkness"
Expand All @@ -73,15 +75,8 @@ greet:
and run it like

```bash
# As flags
$ shmux -config="shmux.js" -shell=$(which node) greet -- "Manuel"
$ shmux greet -- "Manuel"
# => Hello Manuel, from greet

# or from environment
export SHMUX_CONFIG="shmux.js"
export SHMUX_SHELL=$(which node)

shmux greet -- "Manuel"
```

## 📄 Documentation
Expand All @@ -98,7 +93,7 @@ More detailed documentation can be found [here](./docs/docs.md).

As long as the language you choose is fine with having strings like `script:` in its syntax, you can just piggy-back on the existing editor support.

For example, if your _shmuxfile_ hosts JavaScript code, calling it `shmux.js` will give you decent syntax highlighting out of the box in most editors.
For example, if your _shmuxfile_ hosts JavaScript code, calling it `shmuxfile.js` will give you decent syntax highlighting out of the box in most editors.

More sophisticated editor support may be coming soon. If you are interested, feel free to open an issue.

Expand Down
35 changes: 23 additions & 12 deletions docs/docs.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
# shmux: Documentation
# shmux

<details>
<summary>Table of contents</summary>
### Table of contents

* [Configuration](#configuration)
* [Runtime](#runtime)

</details>
* [Environment, flags, and defaults](#environment-flags-and-defaults)

## Configuration

Expand All @@ -20,21 +18,34 @@ A script is composed of all the lines in between two script definitions or last

In a nutshell, `shmux` is not opinionated about which languages the script are written in and - so long as the syntax allows[^1] - editor support comes out of the box. Calling the shmuxfile with the most common extension of your language of choice, will make it significantly easier.

For example, a shmuxfile with bash scripts can be called `shmux.bash`. If it was with JavaScript scripts, it can be called `shmux.js`. This will yield pretty decent syntax highlighting.
For example, a shmuxfile with bash scripts can be called `shmuxfile.bash`. If it was with JavaScript scripts, it can be called `shmuxfile.js`. This will yield pretty decent syntax highlighting.

If you need more sophisticated tooling, please [open an Issue](https://github.com/shikaan/shmux/issues).

[^1]: Namely, permits intendations and presence of `script:`.
[^1]: Namely, permits intendations and presence of the `script:` labels.

## Runtime

All the scripts are executed in isolation. Under the hood, `shmux` parses the file, creates a temporary file with it's content and runs it with the specified shell.

This means that all the line in the same script share scope, as if they were on a single file.
This means that all the lines in the same script share scope, as if they were on a single file.

In the runtime, scripts have the following variables available

| Variable | Description |
|--- |--- |
| `$1`..`$9` | Respectively the first 9 arguments passed after the `--` separator
| `$@` | Holds the name of the current running script
| Variable | Description |
|--- |--- |
| `$1`..`$9` | Respectively the first 9 arguments passed after the `--` separator |
| `$@` | Holds the name of the current running script |

## Environment, flags, and defaults

The general rule is that as little configuration as possible should be provided for `shmux` to run. It is in fact possible to provide no configuration and have `shmux` operating on sensible defaults most of the times. However, `shmux` also provides means to customise its behaviour, namely CLI flags and environment variables.

Hierarachy for those configuration points goes as follows: inline configuration (when applicable) takes precedence over everything, CLI flags override environment variables, and lack of any of them will make `shmux` operate on defaults.

In short: `inline configuration > CLI flags > environment variables > defaults` where the `>` means "takes precedence over".

| CLI Flag | Environment Variable | Default | Description |
|--- |--- | --- | --- |
| `-configuration` | `SHMUX_CONFIG` | closest `shmuxfile.*` | Location of the _shmuxfile_. |
| `-shell` | `SHMUX_SHELL` | current `$SHELL` | Interpreter to run the script. Overriden by inline shebang. |
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ func main() {
return
}

script, err := scripts.ReadScript(scriptName, file)
script, err := scripts.ReadScript(scriptName, shell, file)
exceptions.HandleException(err)

output, err := scripts.RunScript(script, scriptName, shell, args)
output, err := scripts.RunScript(script, args)
exceptions.HandleException(err)

fmt.Print(output)
Expand Down
68 changes: 49 additions & 19 deletions pkg/arguments/arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
)

const DEFAULT_CONFIGURATION = "shmux.sh"
const CONFIGURATION_ENVIRONMENT = "SHMUX_CONFIG"
const DEFAULT_SHELL = "/bin/sh"
const SHELL_ENVIRONMENT = "SHMUX_SHELL"
const ARGUMENT_SEPARATOR = "--"
const CONFIGURATION_GLOB = "shmuxfile.*"
const ENVIRONMENT_CONFIGURATION = "SHMUX_CONFIG"
const ENVIRONMENT_SHELL = "SHMUX_SHELL"
const HELP_SCRIPT = "$$$$___HELP___$$$$"
const HELP_TEXT = `usage: shmux [-config <path>] [-shell <path>] <script> -- [arguments ...]
shmux is a utility to run multiple scripts from one file. Scripts can be written in (almost) any language and they don't need to be in the same language.
The scripts are defined in a configuration file called shmuxfile. Similarly to a Makefile for GNU Make, this file serves as a manifest for the scripts to be run.
The scripts are defined in a configuration file called 'shmuxfile'. Similarly to a Makefile for GNU Make, this file serves as a manifest for the scripts to be run.
More information is available at https://github.com/shikaan/shmux.
Expand All @@ -30,20 +30,24 @@ func Parse() (shell string, config string, scriptName string, arguments []string
flag.PrintDefaults()
}

configFlag := flag.String("config", "", fmt.Sprintf("Configuration file path. It falls back to the SHMUX_SHELL environment variable. (default %s)", DEFAULT_CONFIGURATION))
shellFlag := flag.String("shell", "", fmt.Sprintf("Shell to be used to run the scripts. It falls back to the SHMUX_SHELL environment variable. (default %s)", DEFAULT_SHELL))
configFlag := flag.String("config", "", "Configuration file path. It falls back to the closest 'shmuxfile.*' available.")
shellFlag := flag.String("shell", "", "Interpreter used to run the scripts. It defaults to the current $SHELL.")

flag.Parse()

shell = oneOf(*shellFlag, os.Getenv(SHELL_ENVIRONMENT), DEFAULT_SHELL)
config = oneOf(*configFlag, os.Getenv(CONFIGURATION_ENVIRONMENT), DEFAULT_CONFIGURATION)
scriptName = oneOf(flag.Arg(0), HELP_SCRIPT)
shell = oneOf(*shellFlag, os.Getenv(ENVIRONMENT_SHELL), os.Getenv("SHELL"))

config, err = getConfigurationLocation(oneOf(*configFlag, os.Getenv(ENVIRONMENT_CONFIGURATION)))
if err != nil {
return
}

err = validateShell(shell)
if err == nil {
err = validateConfig(config)
if err != nil {
return
}

scriptName = oneOf(flag.Arg(0), HELP_SCRIPT)
arguments = getAdditionalArguments(flag.Args())

return
Expand Down Expand Up @@ -84,17 +88,43 @@ func validateShell(shell string) error {
return nil
}

func validateConfig(config string) error {
_, err := os.Stat(config)
func canOwnerExec(mode os.FileMode) bool {
return mode&0100 != 0
}

// Looks up and returns the absolute path to the configuration file to be found
// in the current working directory or the parent folders
func getConfigurationLocation(configFileName string) (string, error) {
// Utilise provided configuration if any, else do globmatching
searchTerm := oneOf(configFileName, CONFIGURATION_GLOB)
workingDirectory, err := os.Getwd()
if err != nil {
return err
return "", err
}

return nil
}
// Ensure folders are all in the same shape before the comparison
currentDirectory, err := filepath.Abs(workingDirectory)
if err != nil {
return "", err
}

func canOwnerExec(mode os.FileMode) bool {
return mode&0100 != 0
for {
// Error can only be bad patters, hence ignored
matches, _ := filepath.Glob(filepath.Join(currentDirectory, searchTerm))

if len(matches) > 0 {
return matches[0], nil
}

newDirectory := filepath.Dir(currentDirectory)
isRoot := currentDirectory == newDirectory

if isRoot {
return "", fmt.Errorf("cannot find \"%s\" here (%s) or in any parent folder", searchTerm, workingDirectory)
}

currentDirectory = newDirectory
}
}

// Returns arguments provided after ARGUMENT_SEPARATOR
Expand Down
68 changes: 53 additions & 15 deletions pkg/scripts/scripts.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ import (
const TEMP_SCRIPT_FILE = "shmux"

// A script is a slice of lines representing each a LOC
type Script = []string
type Script struct {
Name string
Lines []string
Interpreter string
Options []string
}

// Executes a script in a given shell
// Arguments are positional arguments ($1, $2...) which can be used in the script as replacement
func RunScript(script Script, scriptName string, shell string, arguments []string) (string, error) {
func RunScript(script *Script, arguments []string) (string, error) {
path := getTempScriptPath()
os.RemoveAll(path)

Expand All @@ -30,21 +35,21 @@ func RunScript(script Script, scriptName string, shell string, arguments []strin
}
defer file.Close()

fileContent := strings.Join(script, "\n")
file.WriteString(replaceArguments(fileContent, arguments, scriptName))
fileContent := strings.Join(script.Lines, "\n")
file.WriteString(replaceArguments(fileContent, arguments, script.Name))

out, err := exec.Command(shell, path).Output()
out, err := exec.Command(script.Interpreter, append(script.Options, path)...).Output()
if err != nil {
exceptions.HandleScriptError(scriptName, err, string(out))
exceptions.HandleScriptError(script.Name, err, string(out))
return "", err
}

return string(out), nil
}

// Parses the provided file, retuning the Script whose name is the provided one
func ReadScript(scriptName string, file io.Reader) (Script, error) {
lines := []string{}
func ReadScript(scriptName string, shell string, file io.Reader) (*Script, error) {
script := &Script{Name: scriptName, Interpreter: shell}
availableScripts := []string{}
scanner := bufio.NewScanner(file)

Expand Down Expand Up @@ -78,14 +83,25 @@ func ReadScript(scriptName string, file io.Reader) (Script, error) {
if line == "" {
continue
}
// Identifies the intendeation for this script by looking
// at the whitespace prepending the first line of the script

if firstLine {
// Identifies the intendeation for this script by looking
// at the whitespace prepending the first line of the script
tabLength = len(line) - len(strings.TrimSpace(line))
firstLine = false

// Overrides the interpreter to be used to execute the script,
// if it's provided as hashbang
isShellLine, interpreter, options := readShebang(line)

if isShellLine {
script.Interpreter = interpreter
script.Options = options
continue
}
}

lines = append(lines, line[tabLength:])
script.Lines = append(script.Lines, line[tabLength:])
}
}

Expand All @@ -94,7 +110,7 @@ func ReadScript(scriptName string, file io.Reader) (Script, error) {
return nil, fmt.Errorf("could not find \"%s\". Available scripts: %s", scriptName, strings.Join(availableScripts, ", "))
}

return lines, nil
return script, nil
}

func MakeHelp(file io.Reader) string {
Expand Down Expand Up @@ -141,12 +157,26 @@ const SCRIPT_IDENTIFIER_REGEXP = "^(\\S+):(.*)"
func readScript(line string) (isScriptLine bool, match string) {
r, _ := regexp.Compile(SCRIPT_IDENTIFIER_REGEXP)
submatch := r.FindStringSubmatch(line)
scriptName := get(submatch, 1)

return scriptName != "", scriptName
}

const SHEBANG_REGEXP = "#!\\s?(\\S+)\\s?(.*)"

// Extracts the interpreter and the options from a shebang line
func readShebang(line string) (isShebangLine bool, interpreter string, options []string) {
r, _ := regexp.Compile(SHEBANG_REGEXP)
submatch := r.FindStringSubmatch(line)

if len(submatch) > 1 {
return true, submatch[1]
interpreter = get(submatch, 1)
stringOptions := strings.TrimSpace(get(submatch, 2))

if stringOptions != "" {
options = strings.Split(stringOptions, " ")
}

return false, ""
return interpreter != "", interpreter, options
}

// Caps a slice to a certain length
Expand All @@ -157,3 +187,11 @@ func cap(slice []string, n int) []string {

return slice[:n]
}

func get(slice []string, index int) string {
if len(slice) > index {
return slice[index]
}

return ""
}
Loading

0 comments on commit 5824018

Please sign in to comment.