├── .ameba.yml ├── .circleci └── config.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── shard.lock ├── shard.yml ├── spec ├── build_spec.cr ├── cache_spec.cr ├── crun_spec.cr ├── icons │ ├── complete.svg │ └── reject.svg ├── lock_spec.cr ├── samples │ ├── env.cr │ ├── env.stderr │ ├── false.cr │ ├── false.stderr │ ├── false.stdout │ ├── hello.cr │ ├── hello.stderr │ ├── hello.stdout │ ├── minitest.cr │ ├── minitest.stderr │ ├── pipe │ │ ├── echo.cr │ │ ├── sleep.cr │ │ ├── test_echo.sh │ │ └── test_sleep.sh │ ├── printargs.args │ ├── printargs.cr │ ├── printargs.stderr │ └── printargs.stdout ├── shards_spec.cr └── spec_helper.cr └── src ├── build.cr ├── cache.cr ├── config.cr ├── crun.cr ├── errors.cr ├── lock.cr ├── main.cr ├── shards.cr └── version.cr /.ameba.yml: -------------------------------------------------------------------------------- 1 | Lint/SpecFilename: 2 | Excluded: 3 | - "spec/samples/**/*.cr" 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2.1 3 | 4 | jobs: 5 | test: 6 | docker: 7 | - image: crystallang/crystal:latest 8 | steps: 9 | - checkout 10 | - run: make help 11 | - run: make todo 12 | - run: make tests 13 | - run: make clobber 14 | - run: make release 15 | - run: make sign 16 | - run: mkdir -p ./bin 17 | - run: PREFIX=. make install 18 | - run: PREFIX=. make uninstall 19 | 20 | workflows: 21 | version: 2 22 | ci: 23 | jobs: 24 | - test 25 | ... 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /crun 3 | /lib/ 4 | /crun 5 | /bin/ 6 | /crun.sha256 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | 3 | before_install: 4 | - curl -fsSL https://download.opensuse.org/repositories/devel:languages:crystal/Debian_10/Release.key | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/crystal.gpg > /dev/null 5 | - echo "deb http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_10/ /" | sudo tee /etc/apt/sources.list.d/crystal.list 6 | - sudo apt -qy update 7 | - sudo apt -qy install crystal 8 | 9 | crystal: 10 | - latest 11 | 12 | dist: focal 13 | 14 | os: 15 | - linux 16 | # FIXME: macOS 10.13 error: crystal: no bottle available! 17 | # - osx 18 | 19 | script: 20 | - make help 21 | - make todo 22 | - make tests 23 | - make clobber 24 | - make release 25 | - make sign 26 | - mkdir -p ./bin 27 | - PREFIX=. make install 28 | - PREFIX=. make uninstall 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Laurent Vallar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- 24 | 25 | spec/icons/complete.svg and spec/icons/complete.svg are part of Antü Plasma: 26 | https://en.wikipedia.org/wiki/File:Antu_task-complete.svg 27 | https://en.wikipedia.org/wiki/File:Antu_task-reject.svg 28 | 29 | Copyright (c) 2016 Fabián Alexis 30 | 31 | This work is licensed under the Creative Commons Attribution-ShareAlike 3.0 32 | Unported License. To view a copy of this license, visit 33 | http://creativecommons.org/licenses/by-sa/3.0/ or send a letter to Creative 34 | Commons, PO Box 1866, Mountain View, CA 94042, USA. 35 | 36 | Creative Commons Attribution-Share Alike 3.0 Unported 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .POSIX: 2 | 3 | AUTO_SLEEP = 2 4 | 5 | UNAME = $(shell uname) 6 | 7 | SHARDS ?= shards 8 | CRYSTAL ?= crystal 9 | CRFLAGS ?= --warnings all --error-on-warnings --error-trace 10 | SOURCES = src/*.cr 11 | SPECS = spec/*.cr 12 | 13 | DESTDIR ?= 14 | PREFIX ?= /usr/local 15 | BINDIR ?= $(DESTDIR)$(PREFIX)/bin 16 | INSTALL = /usr/bin/install 17 | 18 | pwd = $(shell pwd) 19 | 20 | ifeq (${UNAME},Darwin) 21 | inotify_program = fswatch 22 | make_inotifywait = $(inotify_program) -1 -r src spec 23 | notify_ok = terminal-notifier -appIcon file://$(pwd)/.complete.png \ 24 | -title "crun $(1)" -message passed 25 | notify_fail = terminal-notifier -appIcon file://$(pwd)/.reject.png \ 26 | -title "crun $(1)" -message failed 27 | else 28 | inotify_program = inotifywait 29 | make_inotifywait = $(inotify_program) -qq -e close_write -r src spec 30 | notify_ok = notify-send -i $(pwd)/spec/icons/complete.svg "crun $(1)" passed 31 | notify_fail = notify-send -i $(pwd)/spec/icons/reject.svg "crun $(2)" failed 32 | endif 33 | 34 | has_inotify = $(shell [ -n "$$(which $(inotify_program))" ] && echo Ok) 35 | 36 | tty_notify_ok = printf "\033[1;49;92mcrun $(1) passed\033[0m\n" 37 | tty_notify_fail = printf "\033[1;49;91mcrun $(1) failed\033[0m\n" 38 | 39 | ifeq ($(has_inotify),Ok) 40 | make_notify = \ 41 | ( $(MAKE) --no-print-directory $(1) \ 42 | && $(call tty_notify_ok,$(2)) && $(call notify_ok,$(2)) \ 43 | || ( $(call tty_notify_fail,$(2)) && $(call notify_fail,$(2)); false ) ) 44 | else 45 | make_notify = \ 46 | ( $(MAKE) --no-print-directory $(1) \ 47 | && $(call tty_notify_ok,$(2)) || ( $(call tty_notify_fail,$(2)); false ) ) 48 | endif 49 | 50 | .%.png: spec/icons/%.svg 51 | convert -background none -resize 256x256 $< $@ 52 | 53 | all: help 54 | 55 | auto: ## Run tests suite continuously on writes 56 | @+while true; do \ 57 | make --no-print-directory tests && \ 58 | echo "⇒ \033[1;49;92mauto tests done\033[0m, sleeping $(AUTO_SLEEP)s…"; \ 59 | sleep $(AUTO_SLEEP); \ 60 | $(call make_inotifywait); \ 61 | done 62 | 63 | bin/ameba: 64 | $(SHARDS) install 65 | 66 | binfmt: crun ## Add Linux binfmt support 67 | echo ":crystal:E::cr::$(BINDIR)/crun:OC" \ 68 | | sudo tee /proc/sys/fs/binfmt_misc/register 69 | 70 | check: bin/ameba ## Run Ameba static code check 71 | ./bin/ameba 72 | 73 | clean: ## Remove crun builded binary 74 | rm -f crun crun.sha256 75 | 76 | clobber: clean ## Clean and remove editor backup files (*~) 77 | find . -type f -name \*~ -exec rm -f {} \+ 78 | rm -rf bin lib .crun 79 | 80 | crun: $(SOURCES) ## Build crun binary 81 | $(CRYSTAL) build src/main.cr -o crun $(CRFLAGS) 82 | 83 | dev4osx: ## Prepare for dev. on Osx 84 | brew tap veelenga/tap 85 | brew install ameba crystal fswatch imagemagick terminal-notifier 86 | 87 | githook: ## Install Git pre-commit hook 88 | @printf "#!/bin/sh\nmake tests\n" > .git/hooks/pre-commit 89 | @chmod a+rx .git/hooks/pre-commit 90 | 91 | help: ## Show this help 92 | @printf '\033[32mtargets:\033[0m\n' 93 | @grep -E '^[a-zA-Z _-]+:.*?## .*$$' $(MAKEFILE_LIST) |\ 94 | sort |\ 95 | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n",$$1,$$2}' 96 | 97 | install: crun ## Install crun binary 98 | $(INSTALL) -m 0755 crun "$(BINDIR)" 99 | 100 | format: ## Run Crystal format tool 101 | $(CRYSTAL) tool format -i src -i spec 102 | 103 | png: \ 104 | $(patsubst spec/icons/%,.%,$(patsubst %.svg,%.png,$(wildcard spec/icons/*.svg))) 105 | 106 | release: $(SOURCES) ## Build crun binary 107 | $(CRYSTAL) build src/main.cr --release --no-debug -o crun $(CRFLAGS) 108 | 109 | sign: release 110 | shasum -a256 crun > crun.sha256 111 | 112 | spec: $(SPECS) crun ## Run crun specs 113 | $(CRYSTAL) spec --debug --warnings all --error-on-warnings --error-trace 114 | 115 | tests: ## Run tests suite 116 | @+$(call make_notify,format,format) && \ 117 | $(call make_notify,clean,clean) && \ 118 | $(call make_notify,crun,build) && \ 119 | $(call make_notify,spec,spec) && \ 120 | $(call make_notify,check,check) 121 | 122 | todo: ## Show fixme and todo comments 123 | @find . -type f -name \*.cr -exec \ 124 | egrep --color=auto -e '(TODO|FIXME):' {} \+ 2> /dev/null || true 125 | 126 | uninstall: ## Uninstall crun binary 127 | rm -f "$(BINDIR)/crun" 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis-CI Build Status](https://app.travis-ci.com/Val/crun.svg?branch=master)](https://app.travis-ci.com/Val/crun) 2 | [![CircleCI Build Status](https://circleci.com/gh/Val/crun.svg?style=shield)](https://circleci.com/gh/Val/crun) 3 | [![Release](https://img.shields.io/github/release/Val/crun.svg?maxAge=360)](https://github.com/Val/crun/releases) 4 | 5 | # crun 6 | Crystal Run : shebang wrapper for Crystal 7 | 8 | **crun** is a tool enabling one to put a "bang line" in the source code of 9 | a Crystal program to run it, or to run such a source code file explicitly. 10 | It was inspired by [gorun](https://github.com/erning/gorun) and created in 11 | an attempt to make experimenting with Crystal more appealing to people 12 | used to Ruby and similar languages which operate most visibly with source 13 | code. 14 | 15 | ## Example 16 | 17 | As an example, copy the following content to a file named "hello.cr" (or 18 | "hello", if you prefer): 19 | 20 | ```Crystal 21 | #!/usr/bin/env crun 22 | 23 | puts "Hello world" 24 | ``` 25 | 26 | Then, simply run it: 27 | 28 | 29 | ``` 30 | $ chmod +x hello.cr 31 | $ ./hello.cr 32 | Hello world! 33 | ``` 34 | 35 | ## Features 36 | 37 | **crun** will: 38 | 39 | * write files under a safe directory in `$CRUN_CACHE_PATH`, 40 | `$XDG_CACHE_HOME/crun`, `~/.cache/crun`, `~/.cache/.crun` or `.crun` 41 | in this order, so that the actual script location isn't touched 42 | (may be read-only) 43 | * avoid races between parallel compilation of the same file 44 | * automatically clean up old compiled files that remain unused for 45 | some time, by default each 7 days but can be overriden by setting 46 | `CLEAN_CACHE_DAYS` 47 | * replace the process rather than using a child 48 | * pass arguments to the compiled application properly 49 | * handle well shards with comment containing `dependencies` of a 50 | classical `shards.yml` file. Anchors used can be changed by settings 51 | `CRUN_SHARDS_START_ANCHOR` (default: `---`) and 52 | `CRUN_SHARD_END_ANCHOR` (default: `...`). 53 | 54 | ## Shards support example 55 | 56 | ```Crystal 57 | #!/usr/bin/env crun 58 | # --- 59 | # minitest: 60 | # github: ysbaddaden/minitest.cr 61 | # ... 62 | 63 | class Foo 64 | def bar 65 | "baz" 66 | end 67 | end 68 | 69 | require "minitest/autorun" 70 | 71 | class FooTest < Minitest::Test 72 | def foo 73 | @foo ||= Foo.new 74 | end 75 | 76 | def test_that_foo_bar_baz 77 | assert_equal "baz", foo.bar 78 | end 79 | end 80 | 81 | describe Foo do 82 | let(:foo) { Foo.new } 83 | 84 | describe "when asked about bar" do 85 | it "must respond baz" do 86 | foo.bar.must_equal("baz") 87 | end 88 | end 89 | end 90 | 91 | ``` 92 | 93 | ## Where are the compiled files kept? 94 | 95 | They are kept under `$CRUN_CACHE_PATH`, `$XDG_CACHE_HOME/crun`, 96 | `~/.cache/crun`, `~/.cache/.crun` or `.crun` in this order, in a directory 97 | named after the hostname and the slug of the source file name. 98 | 99 | You can remove these files, but there's no reason to do this. These 100 | compiled files will be garbage collected by **crun** itself after a while 101 | once they stop being used. This is done in a fast and safe way so that 102 | concurrently executing scripts will not fail to execute. 103 | 104 | ## How to build and install crun from source 105 | 106 | ```Shell 107 | make release 108 | make install 109 | ``` 110 | 111 | You can change `PREFIX` or `BINDIR` environment variable, see `Makefile` 112 | 113 | ## Usage 114 | 115 | ```Shell 116 | usage: crun [...] 117 | ``` 118 | 119 | # Add Linux binfmt support 120 | 121 | ``` Shell 122 | echo ':crystal:E::cr::/usr/local/bin/crun:OC' \ 123 | | sudo tee /proc/sys/fs/binfmt_misc/register 124 | ``` 125 | or 126 | ```Shell 127 | make binfmt 128 | ``` 129 | 130 | ## Development 131 | 132 | ### Install Git pre-commit hook 133 | 134 | ```Shell 135 | make githook 136 | ``` 137 | 138 | ### Makefile help 139 | 140 | ```Shell 141 | > make 142 | targets: 143 | auto Run tests suite continuously on writes 144 | binfmt Add Linux binfmt support 145 | check Run Ameba static code check 146 | clean Remove crun builded binary 147 | clobber Clean and remove editor backup files (*~) 148 | crun Build crun binary 149 | format Run Crystal format tool 150 | githook Install Git pre-commit hook 151 | help Show this help 152 | install Install crun binary 153 | release Build crun binary 154 | spec Run crun specs 155 | tests Run tests suite 156 | todo Show fixme and todo comments 157 | uninstall Uninstall crun binary 158 | ``` 159 | 160 | ### OsX (for fancy autotests / continuous testing) 161 | 162 | ```Shell 163 | brew tap veelenga/tap 164 | brew install ameba crystal fswatch imagemagick terminal-notifier 165 | ``` 166 | or 167 | ```Shell 168 | make osx 169 | ``` 170 | 171 | ### Debian/Ubuntu (for fancy autotests / continuous testing) 172 | 173 | ```Shell 174 | apt install -y -q inotify-tools libnotify-bin 175 | ``` 176 | 177 | ## Contributing 178 | 179 | 1. Fork it () 180 | 2. Create your feature branch (`git checkout -b my-new-feature`) 181 | 3. Commit your changes (`git commit -am 'Add some feature'`) 182 | 4. Push to the branch (`git push origin my-new-feature`) 183 | 5. Create a new Pull Request 184 | 185 | ## Contributors 186 | 187 | - [Val](https://github.com/Val) Laurent Vallar - creator, maintainer 188 | - [bew](https://github.com/bew) Benoit de Chezelles 189 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 1.6.1 6 | 7 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crun 2 | version: 1.0.5 3 | 4 | authors: 5 | - Laurent Vallar <> 6 | 7 | targets: 8 | crun: 9 | main: src/main.cr 10 | 11 | crystal: 1.11.1 12 | 13 | development_dependencies: 14 | ameba: 15 | github: crystal-ameba/ameba 16 | 17 | license: MIT 18 | -------------------------------------------------------------------------------- /spec/build_spec.cr: -------------------------------------------------------------------------------- 1 | require "../src/build" 2 | require "./spec_helper" 3 | 4 | describe :build do 5 | it "make directory build_dir" do 6 | dir = Crun.build_dir 7 | 8 | File.directory?(dir).should eq(true) 9 | File.readable?(dir).should eq(true) 10 | File.executable?(dir).should eq(true) 11 | File.writable?(dir).should eq(true) 12 | end 13 | 14 | it "define build_path" do 15 | file_path = Crun.build_path 16 | 17 | File.dirname(file_path).should eq(Crun.build_dir) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/cache_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe :cache do 4 | it "clean old pathes" do 5 | spec_recent_path = File.join(SPEC_LOCAL_CACHE_PATH, "recent") 6 | spec_old_path = File.join(SPEC_LOCAL_CACHE_PATH, "old") 7 | 8 | Dir.mkdir(spec_recent_path) 9 | Dir.mkdir(spec_old_path) 10 | 11 | File.touch(spec_old_path, Time.utc - (Crun::CLEAN_CACHE_DAYS + 1).days) 12 | 13 | Crun.clean_cache 14 | 15 | File.directory?(spec_recent_path).should eq(true) 16 | File.exists?(spec_old_path).should eq(false) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/crun_spec.cr: -------------------------------------------------------------------------------- 1 | require "../src/version" 2 | require "./spec_helper" 3 | 4 | unless File.executable?(File.join(ROOT, "crun")) 5 | puts "crun build needed… " 6 | 7 | unless Dir.cd(ROOT) { system("make crun") } 8 | puts "build \033[1;49;91mfailed\033[0m" 9 | exit(1) 10 | end 11 | 12 | puts "build \033[1;49;92mdone\033[0m" 13 | end 14 | 15 | def crun(args : Array(String) = [] of String, 16 | env : Process::Env = nil, 17 | input : Process::Stdio = Process::Redirect::Pipe, 18 | output : Process::Stdio = Process::Redirect::Pipe, 19 | error : Process::Stdio = Process::Redirect::Pipe) 20 | Process.run( 21 | command: "./crun", 22 | args: args, 23 | env: { 24 | "HOME" => ENV.fetch("HOME"), 25 | "CRUN_CACHE_PATH" => SPEC_LOCAL_CACHE_PATH, 26 | "CRYSTAL_SPEC" => "1", 27 | }, 28 | clear_env: false, 29 | shell: true, 30 | input: input, 31 | output: output, 32 | error: error, 33 | chdir: ROOT 34 | ) 35 | end 36 | 37 | def build_error_regex(path) 38 | <<-EOREGEX 39 | ^Crun::BuildError: Build failed: crystal build --debug --error-on-warnings \ 40 | --error-trace -o .+ #{Regex.escape(path)} 41 | 42 | STDOUT: 43 | EOREGEX 44 | end 45 | 46 | describe :crun do 47 | usage = "usage: crun [...]\n" 48 | 49 | it "show version" do 50 | %w[-v --version].each do |arg| 51 | output, error = IO::Memory.new, IO::Memory.new 52 | 53 | status = crun(args: [arg], error: error, output: output) 54 | 55 | error.to_s.should eq("") 56 | output.to_s.should eq("crun #{Crun::VERSION}\n") 57 | error.empty?.should eq(true) 58 | status.success?.should eq(true) 59 | 60 | output.close 61 | error.close 62 | end 63 | end 64 | 65 | it "fail and print usage when no arguments" do 66 | output, error = IO::Memory.new, IO::Memory.new 67 | 68 | status = crun(error: error, output: output) 69 | 70 | output.empty?.should eq(true) 71 | error.to_s.should( 72 | eq("Crun::NoArgumentError: Missing at least one argument\n#{usage}") 73 | ) 74 | status.success?.should eq(false) 75 | 76 | output.close 77 | error.close 78 | end 79 | 80 | it "fail and print usage when invalid source argument" do 81 | %w[/nonexistant /proc/slabinfo].each do |path| 82 | output, error = IO::Memory.new, IO::Memory.new 83 | 84 | status = crun(args: [path], error: error, output: output) 85 | 86 | output.empty?.should eq(true) 87 | error.to_s.should( 88 | eq( 89 | <<-EOSTDOUT 90 | Crun::InvalidSourceError: Cannot read #{path} Crystal source 91 | #{usage} 92 | EOSTDOUT 93 | ) 94 | ) 95 | status.success?.should eq(false) 96 | 97 | output.close 98 | error.close 99 | end 100 | end 101 | 102 | it "fail and print usage when build unsuccessful" do 103 | tempfile = File.tempfile("foo") do |file| 104 | file.puts("puts 'invalid crystal code'") 105 | end 106 | 107 | file_path = tempfile.path 108 | 109 | output, error = IO::Memory.new, IO::Memory.new 110 | 111 | status = crun(args: [file_path], error: error, output: output) 112 | 113 | output.empty?.should eq(true) 114 | 115 | error.to_s.should(match(/#{build_error_regex(file_path)}/)) 116 | status.success?.should eq(false) 117 | 118 | output.close 119 | error.close 120 | ensure 121 | tempfile.delete if tempfile 122 | end 123 | 124 | it "works with samples" do 125 | Dir.glob(File.join(ROOT, "spec/samples/*.cr")).each do |sample_path| 126 | sample_path_base = sample_path.gsub(/\.cr$/, "") 127 | args_path = "#{sample_path_base}.args" 128 | stdout_path = "#{sample_path_base}.stdout" 129 | stderr_path = "#{sample_path_base}.stderr" 130 | 131 | has_args = File.exists?(args_path) 132 | has_stdout = File.exists?(stdout_path) 133 | has_stderr = File.exists?(stderr_path) 134 | 135 | args = has_args ? File.read(args_path).chomp.split("\n") : [] of String 136 | stdout = has_stdout ? File.read(stdout_path) : "" 137 | stderr = has_stderr ? File.read(stderr_path) : "" 138 | 139 | output, error = IO::Memory.new, IO::Memory.new 140 | 141 | status = crun( 142 | args: [sample_path, args].flatten, 143 | error: error, 144 | output: output 145 | ) 146 | 147 | output.to_s.should(eq(stdout)) if has_stdout 148 | error.to_s.should(eq(stderr)) if has_stderr 149 | 150 | status.success?.should(eq(sample_path.match(/false\.cr$/).nil?)) 151 | 152 | shards_config_path = 153 | File.join(Crun.cache_path, Crun.build_name(sample_path), "shard.yml") 154 | 155 | if File.exists?(shards_config_path) # check shard.yml not rebuilded 156 | modification_time = File.info(shards_config_path).modification_time 157 | 158 | status = crun( 159 | args: [sample_path, args].flatten, 160 | error: error, 161 | output: output 162 | ) 163 | 164 | status.success?.should(eq(sample_path.match(/false\.cr$/).nil?)) 165 | 166 | File.info(shards_config_path) 167 | .modification_time 168 | .should(eq(modification_time)) 169 | end 170 | 171 | output.close 172 | error.close 173 | end 174 | end 175 | 176 | it "works with pipe samples" do 177 | Dir.cd(PIPE_SAMPLES_DIR) do 178 | Dir.glob(File.join(PIPE_SAMPLES_DIR, "*.sh")).each do |sample_path| 179 | input = Process::Redirect::Pipe 180 | output, error = IO::Memory.new, IO::Memory.new 181 | 182 | status = Process.run( 183 | command: "sh", 184 | args: [sample_path], 185 | env: { 186 | "HOME" => ENV.fetch("HOME"), 187 | "CRUN_CACHE_PATH" => SPEC_LOCAL_CACHE_PATH, 188 | "CRYSTAL_SPEC" => "1", 189 | "PATH" => [ 190 | ROOT, 191 | PIPE_SAMPLES_DIR, 192 | ENV.fetch("PATH"), 193 | ].compact.join(":"), 194 | }, 195 | clear_env: true, 196 | shell: true, 197 | input: input, 198 | output: output, 199 | error: error, 200 | chdir: PIPE_SAMPLES_DIR, 201 | ) 202 | 203 | error_empty = error.empty? 204 | STDERR.puts("error:\n#{error}") unless error_empty 205 | 206 | error_empty.should eq(true) 207 | status.success?.should(eq(true)) 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /spec/icons/complete.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /spec/icons/reject.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/lock_spec.cr: -------------------------------------------------------------------------------- 1 | require "../src/lock" 2 | require "./spec_helper" 3 | 4 | describe :lock do 5 | it "accept a block" do 6 | object = Random::Secure.hex 7 | 8 | Crun.with_lock { object }.should eq(object) 9 | end 10 | 11 | it "locks exclusively lockfile" do 12 | Crun.with_lock do 13 | path = Crun.lockfile_path 14 | 15 | File.exists?(path).should eq(true) 16 | 17 | expect_raises(IO::Error) do 18 | File.new(path).flock_exclusive(false) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/samples/env.cr: -------------------------------------------------------------------------------- 1 | ENV.keys.sort!.each do |key| 2 | puts "#{key}=#{Regex.escape(ENV[key])}" 3 | end 4 | -------------------------------------------------------------------------------- /spec/samples/env.stderr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Val/crun/9982724be16005f1a62f446952e7f684775bd746/spec/samples/env.stderr -------------------------------------------------------------------------------- /spec/samples/false.cr: -------------------------------------------------------------------------------- 1 | STDERR.puts "false" 2 | exit(1) 3 | -------------------------------------------------------------------------------- /spec/samples/false.stderr: -------------------------------------------------------------------------------- 1 | false 2 | -------------------------------------------------------------------------------- /spec/samples/false.stdout: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Val/crun/9982724be16005f1a62f446952e7f684775bd746/spec/samples/false.stdout -------------------------------------------------------------------------------- /spec/samples/hello.cr: -------------------------------------------------------------------------------- 1 | puts "Hello world" 2 | -------------------------------------------------------------------------------- /spec/samples/hello.stderr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Val/crun/9982724be16005f1a62f446952e7f684775bd746/spec/samples/hello.stderr -------------------------------------------------------------------------------- /spec/samples/hello.stdout: -------------------------------------------------------------------------------- 1 | Hello world 2 | -------------------------------------------------------------------------------- /spec/samples/minitest.cr: -------------------------------------------------------------------------------- 1 | # --- 2 | # minitest: 3 | # github: ysbaddaden/minitest.cr 4 | # branch: master 5 | # ... 6 | 7 | class Foo 8 | def bar 9 | "baz" 10 | end 11 | end 12 | 13 | require "minitest/autorun" 14 | 15 | class FooTest < Minitest::Test 16 | def foo 17 | @foo ||= Foo.new 18 | end 19 | 20 | def test_that_foo_bar_baz 21 | assert_equal "baz", foo.bar 22 | end 23 | end 24 | 25 | describe Foo do 26 | let(:foo) { Foo.new } 27 | 28 | describe "when asked about bar" do 29 | it "must respond baz" do 30 | foo.bar.must_equal("baz") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/samples/minitest.stderr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Val/crun/9982724be16005f1a62f446952e7f684775bd746/spec/samples/minitest.stderr -------------------------------------------------------------------------------- /spec/samples/pipe/echo.cr: -------------------------------------------------------------------------------- 1 | while line = gets 2 | puts line 3 | end 4 | -------------------------------------------------------------------------------- /spec/samples/pipe/sleep.cr: -------------------------------------------------------------------------------- 1 | sleep 1 2 | -------------------------------------------------------------------------------- /spec/samples/pipe/test_echo.sh: -------------------------------------------------------------------------------- 1 | printf 'hello\nworld\n' | crun echo.cr | crun echo.cr | crun echo.cr 2 | -------------------------------------------------------------------------------- /spec/samples/pipe/test_sleep.sh: -------------------------------------------------------------------------------- 1 | crun sleep.cr | crun sleep.cr | crun sleep.cr 2 | -------------------------------------------------------------------------------- /spec/samples/printargs.args: -------------------------------------------------------------------------------- 1 | foo 2 | --bar 3 | baz=42 4 | -------------------------------------------------------------------------------- /spec/samples/printargs.cr: -------------------------------------------------------------------------------- 1 | ARGV.each_with_index do |arg, index| 2 | puts "#{index}: #{arg.inspect}" 3 | end 4 | -------------------------------------------------------------------------------- /spec/samples/printargs.stderr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Val/crun/9982724be16005f1a62f446952e7f684775bd746/spec/samples/printargs.stderr -------------------------------------------------------------------------------- /spec/samples/printargs.stdout: -------------------------------------------------------------------------------- 1 | 0: "foo" 2 | 1: "--bar" 3 | 2: "baz=42" 4 | -------------------------------------------------------------------------------- /spec/shards_spec.cr: -------------------------------------------------------------------------------- 1 | require "../src/shards" 2 | require "./spec_helper" 3 | 4 | describe :shards_config_path do 5 | it "should point to shard.yml file" do 6 | Crun.shards_config_path.should match(/\/shard.yml/) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "../src/cache" 2 | require "../src/config" 3 | require "../src/errors" 4 | 5 | ROOT = File.expand_path(File.join(File.dirname(__FILE__), "..")) 6 | PIPE_SAMPLES_DIR = File.join(ROOT, "spec", "samples", "pipe") 7 | 8 | SPEC_LOCAL_CACHE_PATH = File.tempfile("crun_spec_local_cache").path 9 | File.delete(SPEC_LOCAL_CACHE_PATH) 10 | Dir.mkdir(SPEC_LOCAL_CACHE_PATH) 11 | 12 | Crun.cache_path = SPEC_LOCAL_CACHE_PATH 13 | 14 | at_exit { FileUtils.rm_rf(SPEC_LOCAL_CACHE_PATH) } 15 | 16 | require "spec" 17 | -------------------------------------------------------------------------------- /src/build.cr: -------------------------------------------------------------------------------- 1 | module Crun 2 | @@build_dir : String | Nil 3 | 4 | def self.build_path 5 | @@build_path ||= "#{build_dir}/#{SOURCE_FILENAME}.crystal" 6 | end 7 | 8 | def self.build_name(path : String) 9 | "#{System.hostname}_#{path.gsub(/[^a-zA-Z0-9]/, "_")}" 10 | end 11 | 12 | def self.build_dir 13 | @@build_dir ||= 14 | File.join(cache_path, build_name(SOURCE)).tap do |path| 15 | Dir.mkdir(path) unless File.directory?(path) 16 | rescue error : File::AlreadyExistsError 17 | # Possible concurrent call, check if readable/executable directory 18 | unless Dir.exists?(path) && Dir.children(path).is_a?(Array(String)) 19 | raise error 20 | end 21 | end 22 | end 23 | 24 | private def self.compile : ErrorHash | Nil 25 | return if build? 26 | 27 | hash = nil 28 | 29 | Dir.cd(build_dir) do 30 | if shards_yaml 31 | current = 32 | File.read(shards_config_path) if File.exists?(shards_config_path) 33 | 34 | # build shard.yml and install shards if none or outdated 35 | if current.nil? || !current.match(/#{shards_yaml}/m) 36 | build_shards_config 37 | hash = build_subprocess("shards", %w[install]) 38 | end 39 | end 40 | 41 | unless hash 42 | args = %w[build] 43 | 44 | if ENV.has_key?("CRYSTAL_SPEC") 45 | args.concat(%w[--debug --error-on-warnings --error-trace]) 46 | end 47 | 48 | args.concat(["-o", build_path, SOURCE]) 49 | 50 | hash = build_subprocess("crystal", args) 51 | end 52 | end 53 | 54 | return hash if hash 55 | end 56 | 57 | private def self.build? 58 | File.exists?(build_path) && \ 59 | File.info(build_path).modification_time > \ 60 | File.info(SOURCE).modification_time 61 | end 62 | 63 | private def self.build_subprocess(command : String, 64 | args : Array(String) = [] of String) 65 | input = IO::Memory.new 66 | output = IO::Memory.new 67 | error = IO::Memory.new 68 | 69 | status = Process.run( 70 | command: command, 71 | args: args, 72 | clear_env: false, 73 | shell: true, 74 | input: input, 75 | output: output, 76 | error: error 77 | ) 78 | 79 | return if status.success? 80 | 81 | output_str = output.to_s 82 | error_str = error.to_s 83 | 84 | input.close 85 | output.close 86 | error.close 87 | 88 | { 89 | command: [command, args].flatten.join(" "), 90 | stdout: output_str, 91 | stderr: error_str, 92 | } 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /src/cache.cr: -------------------------------------------------------------------------------- 1 | require "file_utils" 2 | 3 | module Crun 4 | def self.cache_path=(path : String) 5 | unless File.directory?(path) 6 | raise CacheError.new("Invalid cache_path, not a dir.: #{path.inspect}") 7 | end 8 | 9 | unless File.executable?(path) && File.writable?(path) 10 | raise CacheError.new("Invalid cache_path access rights: #{path.inspect}") 11 | end 12 | 13 | @@cache_path = path 14 | end 15 | 16 | def self.cache_path 17 | @@cache_path ||= find_or_create_cache_path 18 | end 19 | 20 | def self.clean_cache 21 | pathes = Dir.glob([File.join(cache_path, "*")]) 22 | .each_with_object({} of Time => Array(String)) do |path, hash| 23 | modification_time = File.info(path).modification_time.to_utc 24 | hash[modification_time] ||= [] of String 25 | hash[modification_time] << path 26 | end 27 | 28 | return if pathes.empty? 29 | 30 | limit = Time.utc - Time::Span.new(days: CLEAN_CACHE_DAYS) 31 | 32 | pathes.keys.select { |key| key < limit }.sort!.each do |key| 33 | pathes[key].each { |path| FileUtils.rm_rf(path) } 34 | end 35 | end 36 | 37 | private BUILD_DIR = "crun" 38 | private DOT_BUILD_DIR = ".#{BUILD_DIR}" 39 | 40 | private def self.cache_path_candidates 41 | local_cache = ENV["HOME"]?.try { |home| File.join(home, ".cache") } 42 | 43 | [ 44 | ENV["CRUN_CACHE_PATH"]?, 45 | ENV["XDG_CACHE_HOME"]?.try { |cache| File.join(cache, BUILD_DIR) }, 46 | local_cache.try { |path| File.join(path, BUILD_DIR) }, 47 | local_cache.try { |path| File.join(path, DOT_BUILD_DIR) }, 48 | File.join(Dir.current, DOT_BUILD_DIR), 49 | ] 50 | end 51 | 52 | private def self.find_or_create_cache_path 53 | cache_path_candidates.each do |candidate| 54 | next unless candidate 55 | 56 | path = File.expand_path(candidate) 57 | return path if File.directory?(path) 58 | 59 | begin 60 | Dir.mkdir_p(path) 61 | return path 62 | rescue File::Error 63 | end 64 | end 65 | 66 | raise CacheError.new("Failed to find or create cache directory") 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /src/config.cr: -------------------------------------------------------------------------------- 1 | module Crun 2 | SOURCE = ARGV[0]?.try { |source| File.expand_path(source) } || "" 3 | SOURCE_FILENAME = File.basename(SOURCE) 4 | 5 | ARGS = ARGV.size > 1 ? ARGV[1..ARGV.size] : [] of String 6 | 7 | CLEAN_CACHE_DAYS = ENV["CRUN_CLEAN_CACHE_DAYS"]?.try { |v| v.as?(Int32) } || 7 8 | end 9 | -------------------------------------------------------------------------------- /src/crun.cr: -------------------------------------------------------------------------------- 1 | require "env" 2 | 3 | module Crun 4 | def self.run 5 | raise NoArgumentError.new if SOURCE.empty? 6 | 7 | unless File.exists?(SOURCE) && File.readable?(SOURCE) 8 | raise InvalidSourceError.new 9 | end 10 | 11 | compile_error_hash = with_lock do 12 | channel = Channel(Nil | ErrorHash).new 13 | 14 | spawn { channel.send(compile) } 15 | 16 | clean_cache 17 | 18 | channel.receive 19 | end 20 | 21 | if compile_error_hash 22 | raise BuildError.new( 23 | <<-EOBUILDERROR 24 | Build failed: #{compile_error_hash[:command]} 25 | 26 | STDOUT: 27 | #{compile_error_hash[:stdout]} 28 | 29 | STDERR: 30 | #{compile_error_hash[:stderr]} 31 | EOBUILDERROR 32 | ) 33 | end 34 | 35 | env = ENV.to_h.tap do |e| 36 | e["PROGRAM_NAME"] = [SOURCE_FILENAME, ARGS].flatten.join(" ") 37 | end 38 | 39 | Process.exec(build_path, ARGS, env: env) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/errors.cr: -------------------------------------------------------------------------------- 1 | module Crun 2 | alias ErrorHash = NamedTuple(command: String, stdout: String, stderr: String) 3 | 4 | class Error < ::Exception 5 | end 6 | 7 | class NoArgumentError < Error 8 | def initialize(@message = "Missing at least one argument") 9 | end 10 | end 11 | 12 | class InvalidSourceError < Error 13 | def initialize(@message = "Cannot read #{SOURCE} Crystal source") 14 | end 15 | end 16 | 17 | class CacheError < Error 18 | end 19 | 20 | class BuildError < Error 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/lock.cr: -------------------------------------------------------------------------------- 1 | module Crun 2 | @@lockfile : File | Nil 3 | 4 | def self.lockfile_path 5 | lockfile.path 6 | end 7 | 8 | def self.with_lock(&) 9 | lockfile.flock_exclusive { yield } 10 | ensure 11 | unlock 12 | end 13 | 14 | private def self.lockfile 15 | @@lockfile ||= File.new("#{build_path}.lock", "w").tap(&.puts(Process.pid)) 16 | end 17 | 18 | private def self.unlock 19 | lockfile.flock_unlock 20 | begin 21 | File.delete(lockfile.path) 22 | rescue error : File::Error 23 | raise error unless error.class == File::NotFoundError 24 | end 25 | @@lockfile = nil 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/main.cr: -------------------------------------------------------------------------------- 1 | require "./build" 2 | require "./cache" 3 | require "./config" 4 | require "./crun" 5 | require "./errors" 6 | require "./lock" 7 | require "./shards" 8 | require "./version" 9 | 10 | if ARGV[0]?.try(&.match(/^-(v|-version)$/)) 11 | STDOUT.puts "crun #{Crun::VERSION}" 12 | exit(0) 13 | end 14 | 15 | begin 16 | Crun.run 17 | rescue error : Crun::Error 18 | STDERR.puts "#{error.class}: #{error.message}" 19 | STDERR.puts("usage: crun [...]") 20 | exit(1) 21 | end 22 | -------------------------------------------------------------------------------- /src/shards.cr: -------------------------------------------------------------------------------- 1 | module Crun 2 | SHARDS_START_ANCHOR = \ 3 | Regex.escape(ENV.fetch("CRUN_SHARDS_START_ANCHOR", "---")) 4 | SHARDS_END_ANCHOR = \ 5 | Regex.escape(ENV.fetch("CRUN_SHARDS_END_ANCHOR", "...")) 6 | 7 | @@shards_yaml : String | Nil 8 | 9 | private SHARDS_YAML_REGEX = \ 10 | /^# #{SHARDS_START_ANCHOR}\n(.+)# #{SHARDS_END_ANCHOR}$/m 11 | 12 | def self.shards_yaml 13 | @@shards_yaml ||= ( 14 | File.read(SOURCE) 15 | .match(SHARDS_YAML_REGEX) 16 | .try(&.[1]?) 17 | .try(&.gsub(/^# /m, " ")) 18 | ) 19 | end 20 | 21 | def self.build_shards_config 22 | with_lock do 23 | File.open(shards_config_path, "w") do |file| 24 | file.puts <<-EOYAML 25 | --- 26 | name: #{SOURCE_FILENAME} 27 | version: 0.1.0 28 | dependencies: 29 | #{shards_yaml} 30 | ... 31 | EOYAML 32 | end 33 | end 34 | end 35 | 36 | def self.shards_config_path 37 | @@shards_config_path ||= "#{File.join(build_dir, "shard.yml")}" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /src/version.cr: -------------------------------------------------------------------------------- 1 | module Crun 2 | VERSION = "1.0.2" 3 | end 4 | --------------------------------------------------------------------------------