Skip to content

Commit b13d52b

Browse files
committed
Add black-box quotation preprocessor
Introduce {% name body %} quotations, expanded during lexing by an external tool over stdin/stdout. A quotation expands to a sentence fragment that is spliced into the surrounding sentence, so it may replace only part of a sentence (and several may appear in one). The terminating '.' is always written by the user. Locations in the expansion are remapped back to the original source, so errors point at the original quoted text. Usage Write a quotation as {% name body %}, where name selects a handler and body is arbitrary text (the {% %} delimiters nest). The expansion is a fragment, so the surrounding sentence and its '.' are written outside the quotation: op forty_two = {% calc 6 * 7 %}. Quotations compose with ordinary source and with each other in one sentence: op mixed = {% calc 6 * 7 %} + ({% calc 2 + 3 %} * 10). Enabling quotations Quotations run external programs, so the feature is OFF by default and a quotation encountered while it is off is a hard error (never a silent skip or a silent execution). Enable it for a run in one of two ways: easycrypt -enable-quotations compile foo.ec EC_ENABLE_QUOTATIONS=1 easycrypt compile foo.ec It cannot be enabled from easycrypt.project, which ships inside a checked-out tree -- letting it turn the feature on would defeat the safeguard. Only enable quotations for sources you trust. Binding handlers Once enabled, a name is resolved to a shell command in order: a 'quote = name:command' entry in the [general] section of easycrypt.project (relative paths resolved against the project directory); then EC_QUOTE_<NAME>; then EC_QUOTE_CMD; then an executable handlers/<name> (also .py/.sh) next to the source file. Handler protocol The handler reads the body (after a header line) on stdin and writes the expanded source, a form-feed, and a JSON source map on stdout; a non-zero exit reports an error at the quotation. See doc/quotations.rst for the full reference.
1 parent e2bb4eb commit b13d52b

23 files changed

Lines changed: 990 additions & 22 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ uninstall:
4343
$(DUNE) uninstall
4444

4545
unit: build
46-
$(CHECK) unit
46+
$(CHECK) unit quotations-disabled
4747

4848
stdlib: build
4949
$(CHECK) prelude stdlib

config/tests.config

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,11 @@ exclude = examples/MEE-CBC examples/old examples/old/list-ddh !examples/incomple
1414
okdirs = examples/MEE-CBC
1515

1616
[test-unit]
17-
okdirs = tests tests/exception
17+
args = -enable-quotations
18+
okdirs = tests tests/exception tests/quotations tests/quotations-project
19+
20+
# Quotations are disabled by default (they run external programs); a file
21+
# using one must fail when -enable-quotations is NOT given. This scenario
22+
# deliberately omits the flag.
23+
[test-quotations-disabled]
24+
kodirs = tests/quotations-disabled

doc/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ EasyCrypt reference manual
55
:maxdepth: 2
66

77
tactics
8+
quotations

doc/quotations.rst

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
========================================================================
2+
Quotations (external preprocessor)
3+
========================================================================
4+
5+
A *quotation* lets you embed, directly in an EasyCrypt source file, a
6+
fragment written in some other surface syntax, and have EasyCrypt expand it
7+
into ordinary EasyCrypt code by delegating to an **external tool**. The tool
8+
is a black box: EasyCrypt communicates with it over standard input and
9+
standard output, so it can be written in any language.
10+
11+
.. warning::
12+
13+
Quotations run external programs, so the feature is **disabled by
14+
default**. Enable it explicitly with the command-line flag
15+
``-enable-quotations`` or the environment variable
16+
``EC_ENABLE_QUOTATIONS=1``. It cannot be enabled from ``easycrypt.project``
17+
(that file ships inside a checked-out tree, so allowing it to turn the
18+
feature on would defeat the safeguard). While disabled, encountering a
19+
quotation is a hard error, never a silent skip or a silent execution. Only
20+
enable quotations for sources you trust.
21+
22+
Quotations are processed during lexing, before parsing. A quotation expands
23+
to a **sentence fragment**: its tokens are spliced into the surrounding
24+
sentence, so a quotation may stand for only *part* of a sentence and several
25+
quotations may appear in one sentence. The sentence terminator (``.``) is
26+
always written by the user and never produced by a quotation. When the
27+
external tool — or EasyCrypt's handling of its output — produces an error, the
28+
location reported by EasyCrypt is mapped back to the **original** quoted text,
29+
not to the generated code.
30+
31+
------------------------------------------------------------------------
32+
Syntax
33+
------------------------------------------------------------------------
34+
35+
A quotation is delimited by ``{%`` and ``%}``:
36+
37+
.. admonition:: Syntax
38+
39+
``{% {name} {body} %}``
40+
41+
Here:
42+
43+
- ``{name}`` is a lowercase identifier selecting which external *handler*
44+
expands the quotation (see `Configuring handlers`_).
45+
46+
- ``{body}`` is arbitrary text. It runs from the character following
47+
``{name}`` up to the matching ``%}``. The delimiters nest: a ``{% ... %}``
48+
pair occurring inside the body is kept verbatim and does not close the
49+
outer quotation, so a body may itself contain quotation delimiters.
50+
51+
A quotation expands to a sentence *fragment*, so the ``.`` that ends the
52+
sentence is written outside the quotation. For example, a ``calc`` handler
53+
that returns the value of an arithmetic expression::
54+
55+
op forty_two = {% calc 6 * 7 %}.
56+
57+
expands to ``op forty_two = 42.``. Because the expansion is only a fragment,
58+
quotations compose with ordinary source and with each other within one
59+
sentence::
60+
61+
op mixed = {% calc 6 * 7 %} + ({% calc 2 + 3 %} * 10).
62+
63+
It is an error for a quotation's expansion to contain a sentence terminator
64+
(``.``): the fragment must not close the sentence itself.
65+
66+
------------------------------------------------------------------------
67+
Configuring handlers
68+
------------------------------------------------------------------------
69+
70+
A quotation ``name`` is resolved to a shell command in this order:
71+
72+
- a binding in ``easycrypt.project`` (see below);
73+
74+
- ``EC_QUOTE_<NAME>`` — where ``<NAME>`` is ``name`` uppercased — gives the
75+
command for that specific quotation name;
76+
77+
- ``EC_QUOTE_CMD`` — a fallback command used for any quotation whose specific
78+
variable is unset;
79+
80+
- otherwise, an executable ``handlers/<name>`` (also tried with the ``.py``
81+
and ``.sh`` extensions) sitting next to the source file. This lets a
82+
directory of files be self-contained, needing no environment to set up — it
83+
is how the test suite binds its handlers.
84+
85+
The recommended way is the project file. In the ``[general]`` section of
86+
``easycrypt.project``, add one repeatable ``quote`` entry per handler, of the
87+
form ``name:command``::
88+
89+
[general]
90+
quote = calc:handlers/calc.py
91+
quote = verbatim:python3 tools/verbatim.py
92+
93+
The ``command`` is a shell command (so it may include an interpreter and
94+
arguments). When it is, verbatim, a relative path to an existing file, it is
95+
resolved against the directory containing ``easycrypt.project``; otherwise it
96+
is passed to the shell unchanged. Project-file bindings take precedence over
97+
the environment, so the committed configuration is authoritative.
98+
99+
To bind a quotation ad hoc through the environment instead::
100+
101+
export EC_QUOTE_CALC=/path/to/calc-handler
102+
103+
A quotation whose name resolves to no command raises an error located at the
104+
quotation.
105+
106+
------------------------------------------------------------------------
107+
The handler protocol
108+
------------------------------------------------------------------------
109+
110+
For each quotation, EasyCrypt launches the bound command, writes a request to
111+
its standard input, and reads the expansion from its standard output.
112+
113+
Request (sent by EasyCrypt)
114+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
115+
116+
A single header line, followed by the raw body::
117+
118+
#ec-quote v1 name=<name> file=<orig-file> line=<L> col=<C> off=<O>
119+
<body bytes...>
120+
121+
where ``line``/``col`` are the 1-based line and 0-based column of the body's
122+
first character in the original file, and ``off`` is its absolute character
123+
offset.
124+
125+
Response (returned by the handler)
126+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
127+
128+
The expanded EasyCrypt source, then a form-feed byte (``\f``, ``0x0C``), then
129+
a JSON *source map*::
130+
131+
<expanded EasyCrypt source>
132+
\f
133+
{ "segments": [ { "out": [ob, oe], "in": [ib, ie], "kind": "verbatim" },
134+
... ] }
135+
136+
Each segment maps a half-open character range ``[ob, oe)`` of the **output**
137+
back to a range ``[ib, ie)`` of the **body** (offsets relative to the start of
138+
each, the body being the handler's own stdin payload):
139+
140+
- ``"kind": "verbatim"`` — the output range is a character-for-character copy
141+
of the input range (``oe - ob == ie - ib``); reverse mapping is
142+
column-precise.
143+
144+
- ``"kind": "synthesized"`` — the output range was generated by the handler
145+
and has no one-to-one origin; the whole output range is attributed to the
146+
whole input range, so an error there points at the responsible region of
147+
the body rather than at a misleading column.
148+
149+
If the response contains no source-map section, the entire expansion is
150+
attributed to the entire quotation (coarse mapping).
151+
152+
Errors
153+
~~~~~~
154+
155+
A handler that exits with a non-zero status makes EasyCrypt raise an error
156+
located at the quotation, using the handler's standard-error output as the
157+
message.
158+
159+
------------------------------------------------------------------------
160+
Location mapping
161+
------------------------------------------------------------------------
162+
163+
Because the expanded code is lexed and parsed in a separate buffer, the
164+
positions EasyCrypt computes for it would, naively, refer to the generated
165+
text. Using the source map and the body's original offset, EasyCrypt rewrites
166+
those positions so that **every** location it reports — parse errors, type
167+
errors, and printed locations alike — refers to the original source file.
168+
169+
For a ``verbatim`` segment this is exact down to the column; for a
170+
``synthesized`` segment the location collapses to the originating region of
171+
the body.
172+
173+
------------------------------------------------------------------------
174+
Examples
175+
------------------------------------------------------------------------
176+
177+
A ``calc`` handler that evaluates an integer expression returns the resulting
178+
literal as a fragment, so::
179+
180+
op forty_two = {% calc 6 * 7 %}.
181+
182+
expands to ``op forty_two = 42.``.
183+
184+
A ``verbatim`` handler that copies its body through with a single ``verbatim``
185+
segment lets EasyCrypt point at the exact original character on error. Given::
186+
187+
{% verbatim op broken : int = no_such_op + 1 %}.
188+
189+
EasyCrypt reports the unknown-identifier error at the column of ``no_such_op``
190+
inside the quotation, even though that identifier sits at a different offset in
191+
the generated buffer.
192+
193+
.. note::
194+
195+
The result of expanding a quotation is stored in the compiled ``.eco``
196+
file. When iterating on a handler, remove the stale ``.eco`` so the
197+
quotation is expanded afresh.

src/ec.ml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,14 @@ let main () =
398398
ldropts.ldro_idirs;
399399
end;
400400

401+
(* Quotations: enabled only via CLI/env (never easycrypt.project), and the
402+
handlers declared in easycrypt.project are registered only then. *)
403+
EcQuotation.set_enabled options.o_options.o_enable_quotes;
404+
if options.o_options.o_enable_quotes then
405+
List.iter
406+
(fun (name, command) -> EcQuotation.register ~name ~command)
407+
ldropts.ldro_quotes;
408+
401409
(* Initialize printer *)
402410
EcCorePrinting.Registry.register (module EcPrinting);
403411

@@ -780,6 +788,14 @@ let main () =
780788
List.iter
781789
(fun p ->
782790
let loc = p.EP.gl_action.EcLocation.pl_loc in
791+
(* Mechanism B: a location should never escape quotation
792+
position-remapping (EcIo) carrying the synthetic buffer
793+
filename. If one does, collapse it rather than print
794+
meaningless <quotation:...> coordinates. *)
795+
let loc =
796+
if EcQuotation.is_sentinel loc.EcLocation.loc_fname
797+
then EcLocation._dummy else loc
798+
in
783799

784800
(* -upto: if this command starts past the target, print goals and exit *)
785801
if past_upto loc then begin

src/ecIo.ml

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type ecreader_r = {
3838
mutable ecr_atstart : bool;
3939
mutable ecr_trim : int;
4040
mutable ecr_tokens : EcParser.token list;
41+
(* Pre-positioned triples produced by expanding a quotation. These are
42+
emitted (front-first) before any token is read from [ecr_lexbuf], so a
43+
single quotation can expand to several EC sentences across successive
44+
[parse] calls. Positions are already remapped into the original file. *)
45+
mutable ecr_expand : (EcParser.token * L.position * L.position) list;
4146
}
4247

4348
type ecreader = ecreader_r Disposable.t
@@ -48,7 +53,8 @@ let ecreader_of_lexbuf (buffer : Buffer.t) (lexbuf : L.lexbuf) : ecreader_r =
4853
ecr_source = buffer;
4954
ecr_atstart = true;
5055
ecr_trim = 0;
51-
ecr_tokens = []; }
56+
ecr_tokens = [];
57+
ecr_expand = []; }
5258

5359
(* -------------------------------------------------------------------- *)
5460
let lexbuf (reader : ecreader) =
@@ -97,13 +103,71 @@ let finalize (ecreader : ecreader) =
97103
Disposable.dispose ecreader
98104

99105
(* -------------------------------------------------------------------- *)
100-
let lexer ?(checkpoint : _ I.checkpoint option) (ecreader : ecreader_r) =
106+
(* Expand a quotation into a list of pre-positioned token triples. *)
107+
(* The handler output is lexed in its own buffer; each token's positions *)
108+
(* are remapped into the original file via the source map. *)
109+
(* *)
110+
(* A quotation expands to a FRAGMENT that is spliced into the surrounding *)
111+
(* sentence: it may stand for only part of a sentence, and the sentence *)
112+
(* terminator ('.') is always written by the user, never produced by the *)
113+
(* expansion. Hence the fragment must contain no FINAL. *)
114+
let expand_quotation (q : EcQuotation.quotation)
115+
: (EcParser.token * L.position * L.position) list
116+
=
117+
let (expanded, sm) = EcQuotation.run q in
118+
let sub = Lexing.from_string expanded in
119+
Lexing.set_filename sub (EcQuotation.sentinel_fname q);
120+
121+
let remap o = EcQuotation.remap_position sm q o in
122+
123+
let rec collect acc =
124+
let toks =
125+
try EcLexer.main sub
126+
with EcLexer.LexicalError (_, msg) ->
127+
EcQuotation.error q
128+
(Printf.sprintf "lexical error in expansion: %s" msg)
129+
in
130+
(* positions of the lexeme just consumed by EcLexer.main *)
131+
let sp = remap (Lexing.lexeme_start sub) in
132+
let ep = remap (Lexing.lexeme_end sub) in
133+
let acc =
134+
List.fold_left (fun acc tk -> (tk, sp, ep) :: acc) acc toks in
135+
match toks with
136+
| [EcParser.EOF] -> List.rev acc
137+
| _ -> collect acc
138+
in
139+
let triples = collect [] in
140+
141+
(* drop the lexed EOF; the surrounding stream supplies sentence flow *)
142+
let body = List.filter (fun (t, _, _) -> t <> EcParser.EOF) triples in
143+
(* a fragment must not terminate the sentence: the '.' is the user's *)
144+
let isfinal = function EcParser.FINAL _ -> true | _ -> false in
145+
if List.exists (fun (t, _, _) -> isfinal t) body then
146+
EcQuotation.error q
147+
"quotation expansion must be a sentence fragment (it must not contain '.')";
148+
body
149+
150+
(* -------------------------------------------------------------------- *)
151+
let rec lexer ?(checkpoint : _ I.checkpoint option) (ecreader : ecreader_r) =
101152
let lexbuf = ecreader.ecr_lexbuf in
102153

103154
let isfinal = function
104155
| EcParser.FINAL _ -> true
105156
| _ -> false in
106157

158+
(* Emit the next pre-positioned expansion triple, if any. *)
159+
let emit_expand () =
160+
match ecreader.ecr_expand with
161+
| [] -> None
162+
| triple :: rest ->
163+
ecreader.ecr_expand <- rest;
164+
Some triple
165+
in
166+
167+
match emit_expand () with
168+
| Some triple -> triple
169+
| None ->
170+
107171
if ecreader.ecr_atstart then
108172
ecreader.ecr_trim <- ecreader.ecr_lexbuf.Lexing.lex_curr_p.pos_cnum;
109173

@@ -119,6 +183,17 @@ let lexer ?(checkpoint : _ I.checkpoint option) (ecreader : ecreader_r) =
119183
ecreader.ecr_tokens <- tokens
120184
done;
121185

186+
(* Intercept a quotation token: expand it into a fragment and splice it.
187+
A quotation always sits at the head of [ecr_tokens] (its lexer rule
188+
returns a singleton list). An empty fragment is allowed -- recurse to
189+
produce the next real token. *)
190+
match ecreader.ecr_tokens with
191+
| EcParser.QUOTATION q :: queue ->
192+
ecreader.ecr_tokens <- queue;
193+
ecreader.ecr_expand <- expand_quotation q;
194+
lexer ?checkpoint ecreader
195+
| _ ->
196+
122197
let token, queue = List.destruct ecreader.ecr_tokens in
123198

124199
let token, prequeue =

0 commit comments

Comments
 (0)