diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d1d9fe2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,15 @@ +name: "Build" +on: + push: {} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.4.0 + - name: Set-up OCaml 4.14.0 + uses: ocaml/setup-ocaml@v2 + with: + ocaml-compiler: 4.14.0 + - run: opam install . --deps-only --with-test + - run: make diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..265852f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*~ +_build +.merlin +*.install +_opam +.ligo +report.html diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..e9731ed --- /dev/null +++ b/.ocamlformat @@ -0,0 +1,4 @@ +version=0.25.1 +profile=janestreet +let-binding-spacing=sparse +margin=90 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b6c3723 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# Frontend to dune. + +.PHONY: default build install uninstall test clean fmt +.IGNORE: fmt + +default: build + +init: + opam switch list | grep ocaml-base-compiler.4.14.0 || opam switch create . ocaml-base-compiler.4.14.0 -y + eval $(opam env) + +build: + opam exec -- dune build + +test: + opam exec -- dune runtest -f + +install: + opam exec -- dune install + +uninstall: + opam exec -- dune uninstall + +clean: + opam exec -- dune clean +# Optionally, remove all files/folders ignored by git as defined +# in .gitignore (-X). + git clean -dfXq + +fmt: + opam exec -- dune build @fmt + opam exec -- dune promote diff --git a/README.md b/README.md new file mode 100644 index 0000000..74f2dfc --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# ligo-mdx + +## Goal +The project goal is to test ligo code contained in markdown file. For example it'll help to test your documentation or training through a CICD. + +It'll generate a report file named report.html + +## Try it + +You can try now it by running : + +```zsh + make && ./ligo-mdx/_build/default/src/bin/main.exe run ./examples/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.md ligo +``` + +It'll generate a report.html file. +## How to use + +The script is able to read 3 keywords in header and the declared language. + +Language can be jsligo, cameligo or zsh (in case of shell script) + +- group : used to regroup multiple snippets, it'll also be used as a nameFile +- compilation : The way to test your code + - Interpret : Use `ligo interpret` to test your code + - Contract : Use `ligo compile contract` to test your code + - Test : Use `ligo run test` to test your code + - Command : Use directly command inside snippet + - None : Skip this snippet +- syntax: If the language does not match with the syntax, you can override the syntax with syntax attribute + - cameligo + - jsligo +- interpretation-type : interpretation-type=declaration + - Expression : Default value, nothing happen + - Declaration : When the snippet is interpreted, it'll be transformed to an expression, surrounding the code with `module ASFJNISFX = struct` and `end in ()` + +## Examples + +### Group snippets + +(ommit \ which has been introduced to be able to insert markdown code) +```markdown + +```jsligo group=taco-shop +type taco_supply is record [current_stock : nat; max_price : tez] + +type taco_shop_storage is map (nat, taco_supply) + +type return_ = [list , taco_shop_storage]; + +\``` + +blabla + +```jsligo group=taco-shop compilation=contract +@entry +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage): return_ => { + /* Retrieve the taco_kind from the contracts storage or fail */ + let taco_kind = + match (Map.find_opt (taco_kind_index, taco_shop_storage), { + Some: (k:taco_supply) => k, + None: (_:unit) => (failwith ("Unknown kind of taco") as taco_supply) + }) ; +} +\``` +``` + +Will regroup and test snippets by running `ligo compile contract tmp/taco-shop.jsligo` on a temp file which is defined by +```jsligo +type taco_supply is record [current_stock : nat; max_price : tez] + +type taco_shop_storage is map (nat, taco_supply) + +type return_ = [list , taco_shop_storage]; + +@entry +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage): return_ => { + /* Retrieve the taco_kind from the contracts storage or fail */ + let taco_kind = + match (Map.find_opt (taco_kind_index, taco_shop_storage), { + Some: (k:taco_supply) => k, + None: (_:unit) => (failwith ("Unknown kind of taco") as taco_supply) + }) ; +} +``` + +:warning: It's not file scopped so snippets can be included from different files. But currently snippet order is from top to down of a file, so it's hard to predict which one will be defined before. We will bring an "order" keyword to manage this case. + +### Branch onto group + +It's possible to branch on snippet, for example if you want to define step by step a function : + +```markdown + +```jsligo group=taco-shop;taco-shop2 +type taco_supply is record [current_stock : nat; max_price : tez] + +type taco_shop_storage is map (nat, taco_supply) + +type return_ = [list , taco_shop_storage]; + +\``` + +blabla + + +```jsligo group=taco-shop compilation=contract +@entry +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage): return_ => { + [], taco_shop_storage +} +\``` + +blablablabla + +```jsligo group=taco-shop2 compilation=contract +@entry +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage): return_ => { + /* Retrieve the taco_kind from the contracts storage or fail */ + let taco_kind = + match (Map.find_opt (taco_kind_index, taco_shop_storage), { + Some: (k:taco_supply) => k, + None: (_:unit) => (failwith ("Unknown kind of taco") as taco_supply) + }) ; +} +\``` +``` + +Will create two snippets named `taco-shop` and `taco-shop2` and test them using `ligo compile contract` + +### Use shell command onto a snippet +Some time your documentation contains a bash command and you want to test it too, it's possible : + +``` +```jsligo group=contract/taco-shop + +\``` + +blabla + +```zsh group=contract/taco-shop compilation=command +ligo run dry-run contract/taco-shop.jsligo 4 3 --entry-point main +\``` +``` + +### Build snippet with invisible code + +Some time, your code is not compilable itself. You can add some code between `` and tag it with the correct group. For example : +``` + + +```cameligo group=invisible-snippet compilation=interpret interpretation-type=declaration +type return = operation list * taco_shop_storage +\``` +``` diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..797d7ba --- /dev/null +++ b/dune-project @@ -0,0 +1,3 @@ +(lang dune 3.0) +(name ligo-mdx) ; optional +(version 0.1.0) ; optional diff --git a/examples/docs/taco-shop/tezos-taco-shop-smart-contract.md b/examples/docs/taco-shop/tezos-taco-shop-smart-contract.md new file mode 100644 index 0000000..926dbdf --- /dev/null +++ b/examples/docs/taco-shop/tezos-taco-shop-smart-contract.md @@ -0,0 +1,859 @@ +--- +id: tezos-taco-shop-smart-contract +title: The Taco Shop Smart Contract +--- + +import Syntax from '@theme/Syntax'; +import Link from '@docusaurus/Link'; + +
+ +Meet **Pedro**, our *artisan taco chef*, who has decided to open a +Taco shop on the Tezos blockchain, using a smart contract. He sells +two different kinds of tacos: **el Clásico** and the **Especial +del Chef**. + +To help Pedro open his dream taco shop, we will implement a smart +contract that will manage supply, pricing & sales of his tacos to the +consumers. + +
+ +
Made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY
+
+ +--- + +## Pricing + +Pedro's tacos are a rare delicacy, so their **price goes up** as the +**stock for the day begins to deplete**. + +Each taco kind, has its own `max_price` that it sells for, and a +finite supply for the current sales life-cycle. + +> For the sake of simplicity, we will not implement the Interpretenishing +> of the supply after it has run out. + +### Daily Offer + +|**kind** |id |**available_stock**| **max_price**| +|---|---|---|---| +|Clásico | `1n` | `50n` | `50tez` | +|Especial del Chef | `2n` | `20n` | `75tez` | + +### Calculating the Current Purchase Price + +The current purchase price is calculated with the following formula: + +```cameligo compilation=none +current_purchase_price = max_price / available_stock +``` + +#### El Clásico +|**available_stock**|**max_price**|**current_purchase_price**| +|---|---|---| +| `50n` | `50tez` | `1tez`| +| `20n` | `50tez` | `2.5tez` | +| `5n` | `50tez` | `10tez` | + +#### Especial del chef +|**available_stock**|**max_price**|**current_purchase_price**| +|---|---|---| +| `20n` | `75tez` | `3.75tez` | +| `10n` | `75tez` | `7.5tez`| +| `5n` | `75tez` | `15tez` | + +--- + +## Installing LIGO + +In this tutorial, we will use LIGO's dockerised version, for the sake +of simplicity. You can find the installation instructions +[here](../../intro/installation.md#dockerised-installation). + +## Implementing our First `main` Function + +> From now on we will get a bit more technical. If you run into +> something we have not covered yet - please try checking out the +> [LIGO cheat sheet](../../api/cheat-sheet.md) for some extra tips & tricks. + +To begin implementing our smart contract, we need a *main function*, +that is the first function being executed. We will call it `main` and +it will specify our contract's storage (`int`) and input parameter +(`int`). Of course this is not the final storage/parameter of our +contract, but it is something to get us started and test our LIGO +installation as well. + + + +```pascaligo group=a compilation=contract +(* taco-shop.ligo *) +function main (const parameter : int; const contractStorage : int) : list (operation) * int is + (nil, contractStorage + parameter) +``` + + + + + +```jsligo group=a compilation=contract +let main = ([parameter, contractStorage] : [int, int]) : [list , int] => { + return [ + list([]), contractStorage + parameter + ] +}; +``` + + + + + +```cameligo group=a compilation=contract +let main (parameter, contractStorage : int * int) : operation list * int = + [], contractStorage + parameter +``` + + +Let us break down the contract above to make sure we understand each +bit of the LIGO syntax: + +- **`function main`** - definition of the main function, which takes + the parameter of the contract and the storage +- **`(const parameter : int; const contractStorage : int)`** - + parameters passed to the function: the first is called `parameter` + because it denotes the parameter of a specific invocation of the + contract, the second is the storage +- **`(list (operation) * int)`** - return type of our function, in our + case a tuple with a list of operations, and an `int` (new value for + the storage after a successful run of the contract) +- **`((nil : list (operation)), contractStorage + parameter)`** - + essentially a return statement +- **`(nil : list (operation))`** - a `nil` value annotated as a list + of operations, because that is required by our return type specified + above + - **`contractStorage + parameter`** - a new storage value for our + contract, sum of previous storage and a transaction parameter + +### Running LIGO for the First Time + +To test that we have installed LIGO correctly, and that +`taco-shop.ligo` is a valid contract, we will dry-run it. + +> Dry-running is a simulated execution of the smart contract, based on +> a mock storage value and a parameter. We will later see a better +> way to test contracts: The LIGO test framework + +Our contract has a storage of `int` and accepts a parameter that is +also an `int`. + +The `dry-run` command requires a few parameters: +- **contract** *(file path)* +- **entrypoint** *(name of the main function in the contract)* +- **parameter** *(parameter to execute our contract with)* +- **storage** *(starting storage before our contract's code is executed)* + +It outputs what is returned from our main function: in our case a +tuple containing an empty list (of operations to apply) and the new +storage value, which, in our case, is the sum of the previous storage +and the parameter we have used for the invocation. + + + +```zsh compilation=None +ligo run dry-run taco-shop.ligo 4 3 --entry-point main +# OUTPUT: +# ( LIST_EMPTY() , 7 ) +``` + + + + +```zsh compilation=None +ligo run dry-run taco-shop.mligo 4 3 --entry-point main +# OUTPUT: +# ( LIST_EMPTY() , 7 ) +``` + + + + + +```zsh compilation=None +ligo run dry-run taco-shop.jsligo 4 3 --entry-point main +# OUTPUT: +# ( LIST_EMPTY() , 7 ) +``` + + + +*`3 + 4 = 7` yay! Our CLI & contract work as expected, we can move onto fulfilling Pedro's on-chain dream.* + +--- + +## Designing the Taco Shop's Contract Storage + +We know that Pedro's Taco Shop serves two kinds of tacos, so we will +need to manage stock individually, per kind. Let us define a type, +that will keep the `stock` & `max_price` per kind in a record with two +fields. Additionally, we will want to combine our `taco_supply` type +into a map, consisting of the entire offer of Pedro's shop. + +**Taco shop's storage** + + + +```pascaligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.ligo +type taco_supply is record [current_stock : nat; max_price : tez] + +type taco_shop_storage is map (nat, taco_supply) +``` + + + + +```cameligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.mligo +type taco_supply = { current_stock : nat ; max_price : tez } + +type taco_shop_storage = (nat, taco_supply) map +``` + + + + + +```jsligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.jsligo +type taco_supply = { current_stock : nat , max_price : tez } ; + +type taco_shop_storage = map ; +``` + + + +Next step is to update the `main` function to include +`taco_shop_storage` in its storage. In the meanwhile, let us set the +`parameter` to `unit` as well to clear things up. + +**`taco-shop.ligo`** + + + +```pascaligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.ligo compilation=contract + +type return is list (operation) * taco_shop_storage + +function main (const parameter : unit; const taco_shop_storage : taco_shop_storage) : return is + (nil, taco_shop_storage) +``` + + + + +```cameligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.mligo compilation=contract +type return = operation list * taco_shop_storage + +let main (parameter, taco_shop_storage : unit * taco_shop_storage) : return = + [], taco_shop_storage +``` + + + + + +```jsligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.jsligo compilation=contract +type return_ = [list , taco_shop_storage]; + +let main = ([parameter, taco_shop_storage] : [unit, taco_shop_storage]) : return_ => { + return [list([]), taco_shop_storage] +}; +``` + + + + +### Populating our Storage + +When deploying contract, it is crucial to provide a correct +initial storage value. In our case the storage is type-checked as +`taco_shop_storage`. Reflecting +[Pedro's daily offer](tezos-taco-shop-smart-contract.md#daily-offer), +our storage's value will be defined as follows: + + + +```pascaligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.ligo +const init_storage = map [ + 1n -> record [ current_stock = 50n ; max_price = 50tez ] ; + 2n -> record [ current_stock = 20n ; max_price = 75tez ] ; +] +``` + + + + +```cameligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.mligo +let init_storage = Map.literal [ + (1n, { current_stock = 50n ; max_price = 50tez }) ; + (2n, { current_stock = 20n ; max_price = 75tez }) ; +] +``` + + + + + +```jsligo group=b1;b2;gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.jsligo +let init_storage = Map.literal (list([ + [1 as nat, { current_stock : 50 as nat, max_price : 50 as tez }], + [2 as nat, { current_stock : 20 as nat, max_price : 75 as tez }] +])); +``` + + + +> The storage value is a map with two bindings (entries) distinguished +> by their keys `1n` and `2n`. + +Out of curiosity, let's try to use LIGO `compile-expression` command compile this value down to Michelson. + + + +```zsh compilation=None +ligo compile expression pascaligo --init-file taco-shop.ligo init_storage +# Output: +# +# { Elt 1 (Pair 50 50000000) ; Elt 2 (Pair 20 75000000) } +``` + + + + +```zsh compilation=None +ligo compile expression cameligo --init-file taco-shop.mligo init_storage +# Output: +# +# { Elt 1 (Pair 50 50000000) ; Elt 2 (Pair 20 75000000) } +``` + + + + + +```zsh compilation=None +ligo compile expression jsligo --init-file taco-shop.jsligo init_storage +# Output: +# +# { Elt 1 (Pair 50 50000000) ; Elt 2 (Pair 20 75000000) } +``` + + + +Our initial storage record is compiled to a Michelson map `{ Elt 1 (Pair 50 50000000) ; Elt 2 (Pair 20 75000000) }` +holding the `current_stock` and `max_prize` in as a pair. + +--- + +## Providing another Access Function for Buying Tacos + +Now that we have our stock well defined in form of storage, we can +move on to the actual sales. The `main` function will take a key `id` +from our `taco_shop_storage` map and will be renamed `buy_taco` for +more readability. This will allow us to calculate pricing, and if the +sale is successful, we will be able to reduce our stock because we +have sold a taco! + +### Selling the Tacos for Free + +Let is start by customising our contract a bit, we will: + +- rename `parameter` to `taco_kind_index` + + + +```pascaligo group=b1 +function buy_taco (const taco_kind_index : nat; var taco_shop_storage : taco_shop_storage) : return is + (nil, taco_shop_storage) +``` + + + + +```cameligo group=b1 +let buy_taco (taco_kind_index, taco_shop_storage : nat * taco_shop_storage) : return = + [], taco_shop_storage +``` + + + + + +```jsligo group=b1 +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage) : return_ => { + return [list([]), taco_shop_storage] +}; +``` + + + + +#### Decreasing `current_stock` when a Taco is Sold + +In order to decrease the stock in our contract's storage for a +specific taco kind, a few things needs to happen: + +- retrieve the `taco_kind` from our storage, based on the + `taco_kind_index` provided; +- subtract the `taco_kind.current_stock` by `1n`; +- we can find the absolute value of the subtraction above by + calling `abs` (otherwise we would be left with an `int`); +- update the storage, and return it. + + + +```pascaligo group=b2 compilation=contract +function buy_taco (const taco_kind_index : nat; var taco_shop_storage : taco_shop_storage) : return is { + // Retrieve the taco_kind from the contract's storage or fail + var taco_kind : taco_supply := + case taco_shop_storage[taco_kind_index] of [ + Some (kind) -> kind + | None -> (failwith ("Unknown kind of taco.") : taco_supply) + ]; + + // Decrease the stock by 1n, because we have just sold one + taco_kind.current_stock := abs (taco_kind.current_stock - 1n); + + // Update the storage with the refreshed taco_kind + taco_shop_storage[taco_kind_index] := taco_kind + } with (nil, taco_shop_storage) +``` + + + + +```cameligo group=b2 compilation=contract +let buy_taco (taco_kind_index, taco_shop_storage : nat * taco_shop_storage) : return = + (* Retrieve the taco_kind from the contract's storage or fail *) + let taco_kind = + match Map.find_opt (taco_kind_index) taco_shop_storage with + | Some k -> k + | None -> failwith "Unknown kind of taco" + in + (* Update the storage decreasing the stock by 1n *) + let taco_shop_storage = Map.update + taco_kind_index + (Some { taco_kind with current_stock = abs (taco_kind.current_stock - 1n) }) + taco_shop_storage + in + [], taco_shop_storage +``` + + + + + +```jsligo group=b2 compilation=contract +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage): return_ => { + /* Retrieve the taco_kind from the contracts storage or fail */ + let taco_kind = + match (Map.find_opt (taco_kind_index, taco_shop_storage), { + Some: (k:taco_supply) => k, + None: (_:unit) => (failwith ("Unknown kind of taco") as taco_supply) + }) ; + /* Update the storage decreasing the stock by 1n */ + let taco_shop_storage_ = Map.update ( + taco_kind_index, + (Some (({...taco_kind, current_stock : abs (taco_kind.current_stock - (1 as nat)) }))), + taco_shop_storage ); + return [list([]), taco_shop_storage_] +}; +``` + + + +### Making Sure We Get Paid for Our Tacos + +In order to make Pedro's taco shop profitable, he needs to stop giving +away tacos for free. When a contract is invoked via a transaction, an +amount of tezzies to be sent can be specified as well. This amount is +accessible within LIGO as `Tezos.get_amount`. + +To make sure we get paid, we will: + +- calculate a `current_purchase_price` based on the + [equation specified earlier](tezos-taco-shop-smart-contract.md#calculating-the-current-purchase-price) +- check if the sent amount matches the `current_purchase_price`: + - if not, then our contract will fail (`failwith`) + - otherwise, stock for the given `taco_kind` will be decreased and + the payment accepted + + + +```pascaligo group=gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.ligo compilation=contract +function buy_taco (const taco_kind_index : nat ; var taco_shop_storage : taco_shop_storage) is { + // Retrieve the taco_kind from the contract's storage or fail + var taco_kind := + case taco_shop_storage[taco_kind_index] of [ + Some (kind) -> kind + | None -> failwith ("Unknown kind of taco.") + ]; + + const current_purchase_price = + taco_kind.max_price / taco_kind.current_stock; + + if (Tezos.get_amount ()) =/= current_purchase_price then + // We won't sell tacos if the amount is not correct + failwith ("Sorry, the taco you are trying to purchase has a different price"); + + // Decrease the stock by 1n, because we have just sold one + taco_kind.current_stock := abs (taco_kind.current_stock - 1n); + + // Update the storage with the refreshed taco_kind + taco_shop_storage[taco_kind_index] := taco_kind +} with (nil, taco_shop_storage) +``` + + + + +```cameligo group=gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.mligo compilation=contract +let buy_taco (taco_kind_index, taco_shop_storage : nat * taco_shop_storage) = + (* Retrieve the taco_kind from the contract's storage or fail *) + let taco_kind = + match Map.find_opt (taco_kind_index) taco_shop_storage with + | Some k -> k + | None -> failwith "Unknown kind of taco" + in + let current_purchase_price : tez = taco_kind.max_price / taco_kind.current_stock in + (* We won't sell tacos if the amount is not correct *) + let () = if (Tezos.get_amount ()) <> current_purchase_price then + failwith "Sorry, the taco you are trying to purchase has a different price" + in + (* Update the storage decreasing the stock by 1n *) + let taco_shop_storage = Map.update + taco_kind_index + (Some { taco_kind with current_stock = abs (taco_kind.current_stock - 1n) }) + taco_shop_storage + in + ([], taco_shop_storage) +``` + + + + + +```jsligo group=gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.jsligo compilation=contract +let buy_taco = (taco_kind_index: nat, taco_shop_storage: taco_shop_storage) : return_ => { + /* Retrieve the taco_kind from the contracts storage or fail */ + let taco_kind : taco_supply = + match (Map.find_opt (taco_kind_index, taco_shop_storage), { + Some: k => k, + None: _x => failwith ("Unknown kind of taco") + }) ; + let current_purchase_price : tez = taco_kind.max_price / taco_kind.current_stock ; + /* We won't sell tacos if the amount is not correct */ + if ((Tezos.get_amount ()) != current_purchase_price) { + return failwith ("Sorry, the taco you are trying to purchase has a different price") + } else { + /* Update the storage decreasing the stock by 1n */ + let taco_shop_storage = Map.update ( + taco_kind_index, + (Some (({...taco_kind, current_stock : abs (taco_kind.current_stock - (1 as nat)) }))), + taco_shop_storage ); + return [list([]), taco_shop_storage] + } +}; +``` + + + +Now let's test our function against a few inputs using the LIGO test framework. +For that, we will have another file in which will describe our test: + + + +```pascaligo group=gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.ligo compilation=test +#include "gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.ligo" + +function assert_string_failure (const res : test_exec_result ; const expected : string) is { + const expected = Test.eval(expected) ; +} with + case res of [ + | Fail (Rejected (actual,_)) -> assert (Test.michelson_equal (actual, expected)) + | Fail (_) -> failwith ("contract failed for an unknown reason") + | Success (_) -> failwith ("bad price check") + ] + +const test = { + // Originate the contract with a initial storage + const init_storage = + map [ + 1n -> record [ current_stock = 50n ; max_price = 50tez ] ; + 2n -> record [ current_stock = 20n ; max_price = 75tez ] ; ]; + const (pedro_taco_shop_ta, _code, _size) = Test.originate(buy_taco, init_storage, 0tez) ; + // Convert typed_address to contract + const pedro_taco_shop_ctr = Test.to_contract (pedro_taco_shop_ta); + // Convert contract to address + const pedro_taco_shop = Tezos.address (pedro_taco_shop_ctr); + + // Test inputs + const clasico_kind = 1n ; + const unknown_kind = 3n ; + + // Auxiliary function for testing equality in maps + function eq_in_map (const r : taco_supply; const m : taco_shop_storage; const k : nat) is { + var b := case Map.find_opt(k, m) of [ + | None -> False + | Some (v) -> (v.current_stock = r.current_stock) and (v.max_price = r.max_price) + ] + } with b; + + // Purchasing a Taco with 1tez and checking that the stock has been updated + const ok_case : test_exec_result = Test.transfer_to_contract (pedro_taco_shop_ctr, clasico_kind, 1tez) ; + const _unit = case ok_case of [ + | Success (_) -> { + const storage = Test.get_storage (pedro_taco_shop_ta) ; + } with (assert (eq_in_map (record [ current_stock = 49n ; max_price = 50tez ], storage, 1n) and + eq_in_map (record [ current_stock = 20n ; max_price = 75tez ], storage, 2n))) + | Fail (x) -> failwith ("ok test case failed") + ]; + + // Purchasing an unregistred Taco + const nok_unknown_kind = Test.transfer_to_contract (pedro_taco_shop_ctr, unknown_kind, 1tez) ; + const _u = assert_string_failure (nok_unknown_kind, "Unknown kind of taco") ; + + // Attempting to Purchase a Taco with 2tez + const nok_wrong_price = Test.transfer_to_contract (pedro_taco_shop_ctr, clasico_kind, 2tez) ; + const _u = assert_string_failure (nok_wrong_price, "Sorry, the taco you are trying to purchase has a different price") ; + } with unit +``` + + + + +```cameligo group=gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.mligo compilation=test +#include "gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.mligo" + +let assert_string_failure (res : test_exec_result) (expected : string) = + let expected = Test.eval expected in + match res with + | Fail (Rejected (actual,_)) -> assert (Test.michelson_equal actual expected) + | Fail _ -> failwith "contract failed for an unknown reason" + | Success _ -> failwith "bad price check" + +let test = + (* originate the contract with a initial storage *) + let init_storage = Map.literal [ + (1n, { current_stock = 50n ; max_price = 50tez }) ; + (2n, { current_stock = 20n ; max_price = 75tez }) ; ] + in + let (pedro_taco_shop_ta, _code, _size) = Test.originate_uncurried buy_taco init_storage 0tez in + (* Convert typed_address to contract *) + let pedro_taco_shop_ctr = Test.to_contract pedro_taco_shop_ta in + (* Convert contract to address *) + let pedro_taco_shop = Tezos.address (pedro_taco_shop_ctr) in + + (* Test inputs *) + let clasico_kind = 1n in + let unknown_kind = 3n in + + (* Auxiliary function for testing equality in maps *) + let eq_in_map (r : taco_supply) (m : taco_shop_storage) (k : nat) = + match Map.find_opt k m with + | None -> false + | Some v -> v.current_stock = r.current_stock && v.max_price = r.max_price in + + (* Purchasing a Taco with 1tez and checking that the stock has been updated *) + let ok_case : test_exec_result = Test.transfer_to_contract pedro_taco_shop_ctr clasico_kind 1tez in + let () = match ok_case with + | Success _ -> + let storage = Test.get_storage pedro_taco_shop_ta in + assert ((eq_in_map { current_stock = 49n ; max_price = 50tez } storage 1n) && + (eq_in_map { current_stock = 20n ; max_price = 75tez } storage 2n)) + | Fail x -> failwith ("ok test case failed") + in + + (* Purchasing an unregistred Taco *) + let nok_unknown_kind = Test.transfer_to_contract pedro_taco_shop_ctr unknown_kind 1tez in + let () = assert_string_failure nok_unknown_kind "Unknown kind of taco" in + + (* Attempting to Purchase a Taco with 2tez *) + let nok_wrong_price = Test.transfer_to_contract pedro_taco_shop_ctr clasico_kind 2tez in + let () = assert_string_failure nok_wrong_price "Sorry, the taco you are trying to purchase has a different price" in + () +``` + + + + + +```jsligo group=gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.jsligo compilation=test +#include "gitlab-pages/docs/tutorials/taco-shop/tezos-taco-shop-smart-contract.jsligo" + +let assert_string_failure = (res: test_exec_result, expected: string) => { + let expected_bis = Test.eval (expected) ; + match (res, { + Fail: (x: test_exec_error) => ( + match (x, { + Rejected: (x:[michelson_program,address]) => assert (Test.michelson_equal (x[0], expected_bis)), + Balance_too_low: (_: { contract_too_low : address , contract_balance : tez , spend_request : tez }) => failwith ("contract failed for an unknown reason"), + Other: (_:string) => failwith ("contract failed for an unknown reason") + })), + Success: (_:nat) => failwith ("bad price check") + } ); +} ; + +let test = ((_: unit): unit => { + /* Originate the contract with a initial storage */ + let init_storage = Map.literal (list([ + [1 as nat, { current_stock : 50 as nat, max_price : 50 as tez }], + [2 as nat, { current_stock : 20 as nat, max_price : 75 as tez }] ])) ; + let [pedro_taco_shop_ta, _code, _size] = Test.originate (buy_taco, init_storage, 0 as tez) ; + /* Convert typed_address to contract */ + let pedro_taco_shop_ctr = Test.to_contract (pedro_taco_shop_ta); + /* Convert contract to address */ + let pedro_taco_shop = Tezos.address (pedro_taco_shop_ctr); + + /* Test inputs */ + let clasico_kind = (1 as nat) ; + let unknown_kind = (3 as nat) ; + + /* Auxiliary function for testing equality in maps */ + let eq_in_map = (r: taco_supply, m: taco_shop_storage, k: nat) => + match(Map.find_opt(k, m), { + None: () => false, + Some: (v : taco_supply) => v.current_stock == r.current_stock && v.max_price == r.max_price }) ; + + /* Purchasing a Taco with 1tez and checking that the stock has been updated */ + let ok_case : test_exec_result = Test.transfer_to_contract (pedro_taco_shop_ctr, clasico_kind, 1 as tez) ; + let _u = match (ok_case, { + Success: (_:nat) => { + let storage = Test.get_storage (pedro_taco_shop_ta) ; + assert (eq_in_map({ current_stock : 49 as nat, max_price : 50 as tez }, storage, 1 as nat) && + eq_in_map({ current_stock : 20 as nat, max_price : 75 as tez }, storage, 2 as nat)); }, + Fail: (_: test_exec_error) => failwith ("ok test case failed") + }) ; + + /* Purchasing an unregistred Taco */ + let nok_unknown_kind = Test.transfer_to_contract (pedro_taco_shop_ctr, unknown_kind, 1 as tez) ; + let _u2 = assert_string_failure (nok_unknown_kind, "Unknown kind of taco") ; + + /* Attempting to Purchase a Taco with 2tez */ + let nok_wrong_price = Test.transfer_to_contract (pedro_taco_shop_ctr, clasico_kind, 2 as tez) ; + let _u3 = assert_string_failure (nok_wrong_price, "Sorry, the taco you are trying to purchase has a different price") ; + return unit +}) (); +``` + + + +Let's break it down a little bit: +- we include the file corresponding to the smart contract we want to + test; +- we define `assert_string_failure`, a function reading a transfer + result and testing against a failure. It also compares the failing + data - here, a string - to what we expect it to be; +- `test` is actually performing the tests: Originates the taco-shop + contract; purchasing a Taco with 1tez and checking that the stock + has been updated ; attempting to purchase a Taco with 2tez and + trying to purchase an unregistered Taco. An auxiliary function to + check equality of values on maps is defined. + +> checkout the [reference page](../../reference/test.md) for a more detailed description of the Test API + +Now it is time to use the LIGO command `test`. It will evaluate our +smart contract and print the result value of those entries that start +with `"test"`: + + + +```zsh group=a compilation=command syntax=pascaligo +ligo run test gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.ligo +# Output: +# +# Everything at the top-level was executed. +# - test exited with value (). +``` + + + + +```zsh group=a compilation=command syntax=cameligo +ligo run test gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.mligo +# Output: +# +# Everything at the top-level was executed. +# - test exited with value (). +``` + + + + + +```zsh syntax=jsligo group=gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test compilation=command +ligo run test gitlab-pages/docs/tutorials/get-started/tezos-taco-shop-test.jsligo +# Output: +# +# Everything at the top-level was executed. +# - test exited with value (). +``` + + + + +**The test passed ! That's it - Pedro can now sell tacos on-chain, thanks to Tezos & LIGO.** + +--- + +## 💰 Bonus: *Accepting Tips above the Taco Purchase Price* + +If you would like to accept tips in your contract, simply change the +following line, depending on your preference. + +**Without tips** + + + +```pascaligo compilation=none +if (Tezos.get_amount ()) =/= current_purchase_price then +``` + + + + +```cameligo compilation=none +if (Tezos.get_amount ()) <> current_purchase_price then +``` + + + + +```jsligo compilation=none +if ((Tezos.get_amount ()) != current_purchase_price) +``` + + + +**With tips** + + + +```pascaligo compilation=none +if (Tezos.get_amount ()) < current_purchase_price then +``` + + + + +```cameligo compilation=none +if (Tezos.get_amount ()) >= current_purchase_price then +``` + + + + + +```jsligo compilation=none +if ((Tezos.get_amount ()) >= current_purchase_price) +``` + + diff --git a/ligo-mdx.locked b/ligo-mdx.locked new file mode 100644 index 0000000..0c6f4a5 --- /dev/null +++ b/ligo-mdx.locked @@ -0,0 +1,112 @@ +opam-version: "2.0" +name: "ligo-mdx" +version: "dev" +synopsis: "_Catchy headline_" +description: "_Project description_" +maintainer: "YOUR EMAIL ADDRESS" +authors: "YOUR NAME" +license: "Unlicense" +homepage: "https://github.com/USERNAME/proj" +bug-reports: "https://github.com/USERNAME/proj/issues" +depends: [ + "alcotest" {= "1.7.0" & with-test} + "astring" {= "0.8.5"} + "base" {= "v0.15.1"} + "base-bigarray" {= "base"} + "base-bytes" {= "base"} + "base-threads" {= "base"} + "base-unix" {= "base"} + "base_bigstring" {= "v0.15.0"} + "base_quickcheck" {= "v0.15.0"} + "bin_prot" {= "v0.15.0"} + "camlp-streams" {= "5.0.1"} + "cmdliner" {= "1.2.0"} + "core" {= "v0.15.1"} + "core_kernel" {= "v0.15.0"} + "cppo" {= "1.6.9"} + "csexp" {= "1.5.2"} + "dune" {= "3.9.1"} + "dune-build-info" {= "3.9.1"} + "dune-configurator" {= "3.9.1"} + "either" {= "1.0.0"} + "fieldslib" {= "v0.15.0"} + "fix" {= "20230505"} + "fmt" {= "0.9.0" & with-test} + "fpath" {= "0.7.3"} + "int_repr" {= "v0.15.0"} + "jane-street-headers" {= "v0.15.0"} + "jst-config" {= "v0.15.1"} + "lwt" {= "5.6.1"} + "menhir" {= "20230608"} + "menhirLib" {= "20230608"} + "menhirSdk" {= "20230608"} + "num" {= "1.4"} + "ocaml" {= "4.14.0"} + "ocaml-base-compiler" {= "4.14.0"} + "ocaml-compiler-libs" {= "v0.12.4"} + "ocaml-config" {= "2"} + "ocaml-options-vanilla" {= "1"} + "ocaml-syntax-shims" {= "1.0.0" & with-test} + "ocaml-version" {= "3.6.1"} + "ocamlbuild" {= "0.14.2"} + "ocamlfind" {= "1.9.6"} + "ocamlformat" {= "0.25.1"} + "ocamlformat-lib" {= "0.25.1"} + "ocamlgraph" {= "2.0.0"} + "ocp-indent" {= "1.8.1"} + "ocplib-endian" {= "1.2"} + "parsexp" {= "v0.15.0"} + "ppx_assert" {= "v0.15.0"} + "ppx_base" {= "v0.15.0"} + "ppx_bench" {= "v0.15.1"} + "ppx_bin_prot" {= "v0.15.0"} + "ppx_cold" {= "v0.15.0"} + "ppx_compare" {= "v0.15.0"} + "ppx_custom_printf" {= "v0.15.0"} + "ppx_derivers" {= "1.2.1"} + "ppx_disable_unused_warnings" {= "v0.15.0"} + "ppx_enumerate" {= "v0.15.0"} + "ppx_expect" {= "v0.15.1"} + "ppx_fields_conv" {= "v0.15.0"} + "ppx_fixed_literal" {= "v0.15.0"} + "ppx_hash" {= "v0.15.0"} + "ppx_here" {= "v0.15.0"} + "ppx_ignore_instrumentation" {= "v0.15.0"} + "ppx_inline_test" {= "v0.15.1"} + "ppx_jane" {= "v0.15.0"} + "ppx_let" {= "v0.15.0"} + "ppx_log" {= "v0.15.0"} + "ppx_module_timer" {= "v0.15.0"} + "ppx_optcomp" {= "v0.15.0"} + "ppx_optional" {= "v0.15.0"} + "ppx_pipebang" {= "v0.15.0"} + "ppx_sexp_conv" {= "v0.15.1"} + "ppx_sexp_message" {= "v0.15.0"} + "ppx_sexp_value" {= "v0.15.0"} + "ppx_stable" {= "v0.15.0"} + "ppx_string" {= "v0.15.0"} + "ppx_typerep_conv" {= "v0.15.0"} + "ppx_variants_conv" {= "v0.15.0"} + "ppxlib" {= "0.30.0"} + "re" {= "1.10.4"} + "result" {= "1.5"} + "seq" {= "base"} + "sexplib" {= "v0.15.1"} + "sexplib0" {= "v0.15.1"} + "splittable_random" {= "v0.15.0"} + "stdio" {= "v0.15.0"} + "stdlib-shims" {= "0.3.0"} + "time_now" {= "v0.15.0"} + "topkg" {= "1.0.7"} + "typerep" {= "v0.15.0"} + "uucp" {= "15.0.0"} + "uuseg" {= "15.0.0"} + "uutf" {= "1.0.3"} + "variantslib" {= "v0.15.0"} +] +build: [ + ["dune" "subst"] {pinned} + ["dune" "build" "-p" name "-j" jobs] +] +run-test: ["dune" "runtest" "-p" name] +dev-repo: "git+https://github.com/USERNAME/proj.git" diff --git a/ligo-mdx.opam b/ligo-mdx.opam new file mode 100644 index 0000000..87c9cdc --- /dev/null +++ b/ligo-mdx.opam @@ -0,0 +1,35 @@ +opam-version: "2.0" +maintainer: "YOUR EMAIL ADDRESS" +authors: ["YOUR NAME"] + +homepage: "https://github.com/USERNAME/proj" +bug-reports: "https://github.com/USERNAME/proj/issues" +dev-repo: "git+https://github.com/USERNAME/proj.git" +version: "dev" + +# TODO Pick the relevant SPDX identifier +license: "MIT" + +synopsis: "Ligo-mdx" + +description: """ +Ligo-mdx use ligo compiler to test snippets present in markdowns. +""" + +build: [ + ["dune" "subst"] {pinned} + ["dune" "build" "-p" name "-j" jobs] +] + +run-test: ["dune" "runtest" "-p" name] + +depends: [ + # Jane Street Core + "core" {>= "v0.15.0" & < "v0.16.0"} + "core_kernel" { >= "v0.15.0" & < "v0.16.0"} + "ocamlgraph" + "dune" + "lwt" {= "5.6.1"} + "ocamlformat" { = "0.25.1" } + "alcotest" {with-test} +] diff --git a/src/bin/dune b/src/bin/dune new file mode 100644 index 0000000..a9c5ddf --- /dev/null +++ b/src/bin/dune @@ -0,0 +1,8 @@ +(executables + (names main) + (public_names ligo-mdx) + (libraries + ligo-mdx.markdown_helper + ligo-mdx.compilation_helper + ligo-mdx.common) + (package ligo-mdx)) diff --git a/src/bin/main.ml b/src/bin/main.ml new file mode 100644 index 0000000..29b7966 --- /dev/null +++ b/src/bin/main.ml @@ -0,0 +1,73 @@ +(* + Inspect the first command-line argument (Sys.argv.(1)) + and determine which subcommand to execute, calling + a function from our library accordingly. +*) + +open Printf +(* + You can do anything you want. + You may want to use Arg.parse_argv to read the remaining + command-line arguments. +*) + +let run argv_offset = + if Array.length (Core.Sys.get_argv ()) <= argv_offset + then ( + print_string "run "; + exit 1); + let directory = (Core.Sys.get_argv ()).(2) in + let ligo_executable = (Core.Sys.get_argv ()).(3) in + let snippets = Markdown_helper.parse_markdowns directory in + let report = Compilation_helper.compile_snippets_map ligo_executable snippets in + exit (Common.check_report_for_errors report) + + +(* Add your own subcommands as needed. *) +let subcommands = [ "run", run ] + +let help () = + let subcommand_names = + String.concat "\n" (List.map (fun (name, _f) -> " " ^ name) subcommands) + in + let usage_msg = + sprintf + "Usage: %s SUBCOMMAND [ARGS]\n\ + where SUBCOMMAND is one of:\n\ + %s\n\n\ + For help on a specific subcommand, try:\n\ + %s SUBCOMMAND --help\n" + Sys.argv.(0) + subcommand_names + Sys.argv.(0) + in + eprintf "%s%!" usage_msg + + +let dispatch_subcommand () = + assert (Array.length Sys.argv > 1); + match Sys.argv.(1) with + | "help" | "-h" | "-help" | "--help" -> help () + | subcmd -> + let argv_offset = 3 in + let action = + try List.assoc subcmd subcommands with + | Not_found -> + eprintf "Invalid subcommand: %s\n" subcmd; + help (); + exit 1 + in + action argv_offset + + +let main () = + let len = Array.length Sys.argv in + if len <= 1 + then ( + help (); + exit 1) + else dispatch_subcommand () + + +(* Run now. *) +let () = main () diff --git a/src/lib/common.ml b/src/lib/common.ml new file mode 100644 index 0000000..fbafd5c --- /dev/null +++ b/src/lib/common.ml @@ -0,0 +1,212 @@ +type syntax = string +type group_name = string +type lang = string +type snippet_content = string +type filepath = string +type imports_groups = string list + +(* Expression must be default value *) +type interpretation_type = + | Declaration + | Expression + +(* Interpret must be default value, None to skip compilation *) +type compilation = + | Interpret + | Contract + | Test + | Command + | None + +type execution_result = int +type execution_result_details = string + +let interpretation_type_to_string compilation = + match compilation with + | Declaration -> "Declaration" + | Expression -> "Expression" + + +let interpretation_type_from_string s = + match String.lowercase_ascii s with + | "declaration" -> Declaration + | "expression" -> Expression + | _ -> failwith "unrecognized interpretation_type type" + + +let compilation_to_string compilation = + match compilation with + | Interpret -> "Interpret" + | Contract -> "Contract" + | Test -> "Test" + | Command -> "Command" + | None -> "None" + + +let is_valid_extension extension = + List.exists (fun el -> String.equal extension el) [ "mligo"; "jsligo"; "sh" ] + + +let extension_from_syntax syntax = + match syntax with + | "cameligo" -> ".mligo" + | "jsligo" -> ".jsligo" + | "cameligo.bash" | "cameligo.zsh" -> ".mligo.sh" + | "jsligo.zsh" | "jsligo.bash" -> ".jsligo.sh" + | _ -> "." ^ syntax + + +let compilation_from_string s = + match String.lowercase_ascii s with + | "interpret" -> Interpret + | "contract" -> Contract + | "test" -> Test + | "command" -> Command + | "none" -> None + | _ -> failwith "unrecognized compilation type" + + +module SnippetsGroup = Map.Make (struct + type t = syntax * group_name + + let compare a b = compare a b +end) + +type snippetsmap = (snippet_content * compilation * interpretation_type) SnippetsGroup.t + +type snippets_result_map = + (snippet_content * compilation * execution_result * execution_result_details) + SnippetsGroup.t + +type report_list = snippets_result_map list + +open Core + +let _print_group key (_syntax, content) = + Format.printf "Group: %s - %s\n" (fst key) (snd key); + Format.printf "Content:\n%s\n" content; + Format.printf "-----------------------\n" + + +let print_snippetsmap (snippets : snippetsmap) : unit = + SnippetsGroup.iter + (fun (syntax, group_name) (content, compilation, interpretation_type) -> + Format.printf "Syntax: %s\n" syntax; + Format.printf "Group: %s\n" group_name; + Format.printf "Content:\n%s\n" content; + Format.printf "Compilation: %s\n" (compilation_to_string compilation); + Format.printf + "Interpretation_type:\n%s\n" + (interpretation_type_to_string interpretation_type); + Format.printf "*******************\n") + snippets + + +let _print_report_list (report : report_list) : unit = + List.iter + ~f:(fun snippets -> + SnippetsGroup.iter + (fun (syntax, group_name) + (content, compilation, execution_result, execution_result_details) -> + Format.printf "Syntax: %s\n" syntax; + Format.printf "Group: %s\n" group_name; + Format.printf "Content:\n%s\n" content; + Format.printf + "Compilation: %s\n" + (match compilation with + | Interpret -> "Interpret" + | Contract -> "Contract" + | Test -> "Test" + | Command -> "Command" + | None -> "None"); + Format.printf "Execution Result: %d\n" execution_result; + Format.printf "Execution Result Details: %s\n" execution_result_details; + Format.printf "-----------------------\n") + snippets) + report + + +let contains s1 s2 = + let re = Str.regexp_string s2 in + try + ignore (Str.search_forward re s1 0); + true + with + | _ -> false + + +let get_result_color execution_result execution_result_details = + if execution_result <> 0 + then "red" (* ligne rouge si le résultat n'est pas 0 *) + else if contains execution_result_details "warning" + then "orange" (* ligne orange si "warning" est présent dans les détails du résultat *) + else "black" (* ligne noire par défaut *) + + +let write_report_list_to_html_file (report : report_list) (filename : string) : unit = + let oc = Out_channel.create filename in + Out_channel.output_string oc "\n"; + Out_channel.output_string oc "\n"; + List.iter + ~f:(fun snippets -> + SnippetsGroup.iter + (fun (syntax, group_name) + (content, compilation, execution_result, execution_result_details) -> + let result_color = + if execution_result <> 0 + then "red" (* ligne rouge si le résultat n'est pas 0 *) + else if String.is_substring execution_result_details ~substring:"warning" + then + "orange" + (* ligne orange si "warning" est présent dans les détails du résultat *) + else "black" (* ligne noire par défaut *) + in + Printf.fprintf oc "\n" result_color; + Printf.fprintf oc "\n" syntax; + Printf.fprintf oc "\n" group_name; + Printf.fprintf oc "\n"; + Printf.fprintf + oc + "\n" + content; + Printf.fprintf + oc + "\n" + (match compilation with + | Interpret -> "Interpret" + | Contract -> "Contract" + | Test -> "Test" + | Command -> "Command" + | None -> "None"); + Printf.fprintf oc "\n" execution_result; + Printf.fprintf oc "\n"; + Printf.fprintf + oc + "\n" + execution_result_details; + Printf.fprintf oc "\n") + snippets) + report; + Out_channel.output_string oc "
Syntax: %sGroup: %sContent:
Click to expand
%s
Compilation: %sExecution Result: %dExecution Result Details:
Click to expand
%s
\n"; + Out_channel.close oc + + +let check_report_for_errors (report : report_list) : int = + let has_errors = + Caml.List.exists + (fun snippets -> + SnippetsGroup.exists + (fun _ (_, _, execution_result, _) -> execution_result <> 0) + snippets) + report + in + if has_errors then 1 else 0 diff --git a/src/lib/compilation_helper.ml b/src/lib/compilation_helper.ml new file mode 100644 index 0000000..c9c84ff --- /dev/null +++ b/src/lib/compilation_helper.ml @@ -0,0 +1,178 @@ +open Common + +type extension = string + +let remove_directory path = + let command = "rm -rf " ^ path in + match Unix.system command with + | Unix.WEXITED 0 -> () + | _ -> failwith "Failed to remove directory" + + +let inject_tmp_filepath_to_file (filepath_to_inject : string) (command : string) : string = + let extension = Filename.extension filepath_to_inject in + let pattern = Str.regexp (".*\\(\\ .*\\" ^ extension ^ "\\)") in + if Str.string_match pattern command 0 + then + (let matching_filepath = Str.matched_group 1 command in + Str.global_replace (Str.regexp matching_filepath) (" " ^ filepath_to_inject) command) + ^ " 2>&1" + else command ^ " 2>&1" + + +let write_snippet_to_file (syntax, group_name) (content, _) : filepath = + let extension = + let splitted_filename = String.split_on_char '.' group_name in + if is_valid_extension (List.nth splitted_filename (List.length splitted_filename - 1)) + then "" + else extension_from_syntax syntax + in + let filepath = "tmp/" ^ group_name ^ extension in + let folders_path = + match List.rev (String.split_on_char '/' filepath) with + | _head :: tail -> List.rev tail + | _ -> failwith "filepath cannot be empty" + in + let _s = + List.fold_left + (fun acc folder -> + let () = + if not (Sys.file_exists (acc ^ folder)) then Unix.mkdir (acc ^ folder) 0o755 + in + acc ^ folder ^ "/") + "" + folders_path + in + (* File need to be write to the correct place for include. This solution will not work if a file reference to a file which is referencing to another file*) + Core.Out_channel.write_all filepath ~data:content; + let filename = + match List.rev (String.split_on_char '/' filepath) with + | head :: _tail -> "tmp/" ^ head + | _ -> failwith "filepath cannot be empty" + in + Core.Out_channel.write_all filename ~data:content; + filename + + +let find_snippet_file_for_command_file group syntax : filepath option = + let searched_extension = + Str.global_replace (Str.regexp "\\.sh") "" (extension_from_syntax syntax) + in + let filename = + match List.rev (String.split_on_char '/' group) with + | head :: _tail -> head + | _ -> failwith "filepath cannot be empty" + in + let regex = Str.regexp (filename ^ searched_extension ^ "$") in + let matching_files = Array.to_list (Sys.readdir "tmp") in + let filtered_files = + List.filter (fun file -> Str.string_match regex file 0) matching_files + in + let head = List.nth_opt filtered_files 0 in + match head with + | Some filename -> Some ("tmp/" ^ filename) + | None -> None + + +let execute_command command : int * string = + Printf.printf "\nExecute %s" command; + let ic = Unix.open_process_in command in + let all_output = Buffer.create 1024 in + let exit_code = + try + while true do + let line = input_line ic in + Buffer.add_string all_output line; + Buffer.add_char all_output '\n' + done; + 0 + with + | End_of_file -> + let status = Unix.close_process_in ic in + (match status with + | Unix.WEXITED code | Unix.WSIGNALED code | Unix.WSTOPPED code -> code) + in + exit_code, Buffer.contents all_output + + +let retrieve_commands_from_file (filename : string) : string list = + let commands = ref [] in + let ic = open_in filename in + try + while true do + let line = input_line ic in + if String.length line > 0 && line.[0] <> '#' then commands := line :: !commands + done; + [] + with + | End_of_file -> + close_in ic; + List.rev !commands + + +let compile_snippets_map ligo_executable (snippets_list : snippetsmap list) : report_list = + let () = if not (Sys.file_exists "tmp") then Unix.mkdir "tmp" 0o755 in + let result = + List.fold_left + (fun acc snippets -> + SnippetsGroup.fold + (fun (syntax, group_name) (content, compilation, interpretation_type) acc -> + let filename = + write_snippet_to_file (syntax, group_name) (content, compilation) + in + let return_code, details = + match compilation with + | Interpret -> + (match interpretation_type with + | Declaration -> + (* In this case, we want to transform the declaration into an expression to be interpretable *) + execute_command + (ligo_executable + ^ " run interpret --syntax " + ^ syntax + ^ " \"module ASFJNISFX = struct $(cat " + ^ filename + ^ ") end in () \" 2>&1") + | Expression -> + execute_command + (ligo_executable + ^ " run interpret --syntax " + ^ syntax + ^ " \"$(cat " + ^ filename + ^ ")\" 2>&1")) + | Contract -> + execute_command + (ligo_executable ^ " compile contract " ^ filename ^ " 2>&1") + | Test -> + execute_command (ligo_executable ^ " run test " ^ filename ^ " 2>&1") + | Command -> + let commands = retrieve_commands_from_file filename in + List.fold_left + (fun (output_codes, output_logs_acc) command -> + let output_code, output_logs = + match find_snippet_file_for_command_file group_name syntax with + | Some snippet_file -> + execute_command (inject_tmp_filepath_to_file snippet_file command) + | None -> execute_command (command ^ " 2>&1") + in + ( output_codes + output_code + , output_logs_acc ^ "\n" ^ command ^ ":\n" ^ output_logs )) + (0, "") + commands + | None -> 0, filename + in + let snippets_result = + SnippetsGroup.singleton + (syntax, group_name) + (content, compilation, return_code, details) + in + snippets_result :: acc) + snippets + acc) + [] + snippets_list + in + write_report_list_to_html_file result "report.html"; + remove_directory "tmp"; + result diff --git a/src/lib/dune b/src/lib/dune new file mode 100644 index 0000000..ae5cebe --- /dev/null +++ b/src/lib/dune @@ -0,0 +1,22 @@ +(ocamllex md) + +(library + (name common) + (modules common) + (flags (:standard)) + (libraries core) + (public_name ligo-mdx.common)) + +(library + (name markdown_helper) + (modules markdown_helper md) + (public_name ligo-mdx.markdown_helper) + (libraries unix str core stdio common) + (synopsis "This is a short description of the helper.markdown library.")) + +(library + (name compilation_helper) + (modules compilation_helper) + (public_name ligo-mdx.compilation_helper) + (libraries unix str core stdio common) + (synopsis "This is a short description of the helper.markdown library.")) diff --git a/src/lib/markdown_helper.ml b/src/lib/markdown_helper.ml new file mode 100644 index 0000000..277f2a1 --- /dev/null +++ b/src/lib/markdown_helper.ml @@ -0,0 +1,179 @@ +open Common + +let get_all_md_files directory = + let ic = Unix.open_process_in ("find " ^ directory ^ " -iname \"*.md\"") in + let files = ref [] in + (try + while true do + match In_channel.input_line ic with + | Some line -> files := line :: !files + | None -> raise End_of_file + done + with + | End_of_file -> In_channel.close ic); + List.rev !files + + +open Core + +let arg_to_string x = + match x with + | Md.Field s -> s + | Md.NameValue (k, v) -> Format.asprintf "%s=%s" k v + + +let _print_code_block (block : Md.block) = + Format.printf "Header: %s\n" (Option.value ~default:"" block.header); + Format.printf "Arguments:\n"; + List.iter ~f:(fun arg -> Format.printf "- %s\n" (arg_to_string arg)) block.arguments; + Format.printf "Contents:\n"; + List.iter ~f:(fun content -> Format.printf "- %s\n" content) block.contents; + Format.printf "-----------------------\n" + + +let _print_code_blocks (_code_blocks : Md.block list) = + List.iter ~f:_print_code_block _code_blocks + + +let sort_md_args (args_list : Md.arg list) : Md.arg list = + let compare_args arg1 arg2 = + match arg1, arg2 with + | Md.Field s1, Md.Field s2 -> String.compare s1 s2 + | Md.Field _, Md.NameValue _ -> -1 + | Md.NameValue _, Md.Field _ -> 1 + | Md.NameValue (key1, _), Md.NameValue (key2, _) -> String.compare key1 key2 + in + List.sort ~compare:compare_args args_list + + +let generate_snippetsmap_entry + grp_names + syntax + compilation + contents + grp_map + interpretation_type + : snippetsmap + = + let groups = String.split_on_chars ~on:[ ';' ] grp_names in + List.fold_left groups ~init:grp_map ~f:(fun grp_map name -> + SnippetsGroup.update + (syntax, name) + (fun arg_content -> + match arg_content with + | Some (ct, _, _) -> + Some + ( String.concat ~sep:"\n" (ct :: contents) + , compilation_from_string compilation + , interpretation_type_from_string interpretation_type ) + | _ -> + Some + ( String.concat ~sep:"\n" contents + , compilation_from_string compilation + , interpretation_type_from_string interpretation_type )) + grp_map) + + +let add_default_arg arg_key arg_default (args_list : Md.arg list) : Md.arg list = + let imports_arg_exists = + List.exists + ~f:(fun arg -> + match arg with + | NameValue (arg, _) -> String.equal arg arg_key + | _ -> false) + args_list + in + if imports_arg_exists + then args_list + else Md.NameValue (arg_key, arg_default) :: args_list + + +let get_groups md_file : snippetsmap option = + try + let channel = In_channel.create md_file in + let lexbuf = Lexing.from_channel channel in + let code_blocks = Md.token lexbuf in + let aux : snippetsmap -> Md.block -> snippetsmap = + fun grp_map el -> + (* _print_code_block el; *) + match el.header with + | Some ("cameligo" as s) + | Some ("jsligo" as s) + | Some ("zsh" as s) + | Some ("bash" as s) -> + let () = + (*sanity check*) + List.iter + ~f:(fun arg -> + match arg with + | Md.NameValue ("syntax", _) + | Md.NameValue ("group", _) + | Md.NameValue ("interpretation-type", _) + | Md.NameValue ("compilation", _) -> () + | Md.Field _ | Md.NameValue (_, _) -> + failwith + (Format.asprintf + "unknown argument '%s' in code block at line %d of file %s" + (arg_to_string arg) + el.line + el.file)) + el.arguments + in + let args = add_default_arg "compilation" "interpret" el.arguments in + let args = add_default_arg "interpretation-type" "expression" args in + let args = add_default_arg "group" "ungrouped" args in + let args = add_default_arg "syntax" "" args in + let args = sort_md_args args in + (match args with + (* Every possibilities with group syntax and compilation*) + | [ Md.NameValue ("compilation", compilation) + ; Md.NameValue ("group", names) + ; Md.NameValue ("interpretation-type", interpretation_type) + ; Md.NameValue ("syntax", syntax) + ] -> + let syntax = if String.equal "" syntax then syntax else syntax ^ "." in + generate_snippetsmap_entry + names + (syntax ^ s) + compilation + el.contents + grp_map + interpretation_type + | args -> + let () = + List.iter + ~f:(function + | Md.NameValue (x, y) -> Format.printf "NamedValue %s %s\n" x y + | Md.Field x -> Format.printf "%s\n" x) + args + in + failwith "Block arguments (above) not supported") + | None | Some _ -> grp_map + in + Some (List.fold_left ~f:aux ~init:SnippetsGroup.empty code_blocks) + with + | exn -> + Printf.eprintf "Exception in get_groups: %s\n" (Exn.to_string exn); + None + + +let parse_markdown file_path = + Format.printf "=== File: %s ===\n" file_path; + match get_groups file_path with + | Some snippets -> + Format.printf "\n"; + Some snippets + | None -> + Printf.printf "Due to error, ignoring file: %s\n" file_path; + None + + +let parse_markdowns directory : snippetsmap list = + let md_filepaths = get_all_md_files directory in + List.fold_left + ~f:(fun acc file_path -> + match parse_markdown file_path with + | Some snippets -> snippets :: acc + | None -> acc) + ~init:[] + md_filepaths diff --git a/src/lib/md.mll b/src/lib/md.mll new file mode 100644 index 0000000..7e96163 --- /dev/null +++ b/src/lib/md.mll @@ -0,0 +1,68 @@ +{ +open Core +module Array = Stdlib.Array +(* initial version taken from https://github.com/realworldocaml/mdx *) +type arg = + | Field of string + | NameValue of string * string + +type block = { + line : int; + file : string; + arguments: arg list; + header : string option; + contents: string list; +} + +exception Err of string + +let line_ref = ref 1 + +let newline lexbuf = + Lexing.new_line lexbuf; + incr line_ref +} + +let eol = '\n' | eof +let ws = ' ' | '\t' + +rule text = parse + | eof { [] } + | "```" ([^' ' '\n']* as h) ws* ([^'\n']* as l) eol + { + let header = if String.equal h "" then None else Some h in + let contents = block lexbuf in + let arguments = String.split ~on:' ' l in + let arguments = List.map ~f:(fun a -> + if (String.contains a '=') then + ( let a = String.split ~on:'=' a in + Some (NameValue (List.nth_exn a 0, List.nth_exn a 1))) + else + if String.equal (String.strip a) "" then + None + else + Some (Field (String.strip a)) + ) arguments in + let arguments = List.filter_map ~f:(fun x -> x) arguments in + let file = lexbuf.Lexing.lex_start_p.Lexing.pos_fname in + newline lexbuf; + let line = !line_ref in + List.iter ~f: (fun _ -> newline lexbuf) contents; + newline lexbuf; + { file; line; header; arguments; contents; } + :: text lexbuf } + | [^'\n']* eol + { newline lexbuf; + text lexbuf } + +and block = parse + | eof | "```" ws* eol { [] } + | ([^'\n'] * as str) eol { str :: block lexbuf } + +{ +let token lexbuf = + try + text lexbuf + with Failure _ -> + raise (Err "incomplete code block") +} diff --git a/src/test/dune b/src/test/dune new file mode 100644 index 0000000..8f3fc12 --- /dev/null +++ b/src/test/dune @@ -0,0 +1,4 @@ +(library + (name test_markdown_helper) + (libraries alcotest ligo-mdx.markdown_helper) + (synopsis "Tests for ligo-mdx")) diff --git a/src/test/test_markdown_helper.ml b/src/test/test_markdown_helper.ml new file mode 100644 index 0000000..9134e03 --- /dev/null +++ b/src/test/test_markdown_helper.ml @@ -0,0 +1,5 @@ +(* + Tests for Sub2.A +*) + +let tests = [] diff --git a/test/dune b/test/dune new file mode 100644 index 0000000..285b791 --- /dev/null +++ b/test/dune @@ -0,0 +1,9 @@ +(executable + (name run_tests) + (libraries alcotest test_markdown_helper)) + +(rule + (alias runtest) + (deps run_tests.exe) + (action + (run %{deps} -q --color=always))) diff --git a/test/run_tests.ml b/test/run_tests.ml new file mode 100644 index 0000000..34e7bcb --- /dev/null +++ b/test/run_tests.ml @@ -0,0 +1,6 @@ +(* + Run all the OCaml test suites defined in the project. +*) + +let test_suites : unit Alcotest.test list = [ "Sub1.A", Test_markdown_helper.tests ] +let () = Alcotest.run "proj" test_suites