asimov_runner/programs/compiler.rs
1// This is free and unencumbered software released into the public domain.
2
3//! Natural-language-to-SPARQL compilation through an external compiler program.
4
5use crate::{Executor, ExecutorError, QueryOutput, TextInput};
6use alloc::{boxed::Box, vec::Vec};
7use async_trait::async_trait;
8use derive_more::Debug;
9use std::{ffi::OsStr, io::Cursor, process::Stdio};
10
11pub use asimov_patterns::CompilerOptions;
12
13/// Raw query bytes captured from a successful [`Compiler`], or an execution error.
14///
15/// The cursor is positioned at zero and is empty when stdout is not captured.
16/// Captured bytes, including whitespace and trailing newlines, are preserved.
17/// The pattern requires UTF-8 SPARQL, but this wrapper does not decode or validate it.
18pub type CompilerResult = std::result::Result<Cursor<Vec<u8>>, ExecutorError>;
19
20/// An external [compiler] that translates natural-language text into a SPARQL query.
21///
22/// Input bytes are copied to stdin, relying on the pattern's default input-file
23/// operand of `-`. The external program produces one UTF-8 SPARQL query for an
24/// adapter, without Markdown fences or explanatory prose outside the query.
25/// Dataset and vocabulary assumptions, and compatibility with the intended
26/// adapter's supported query forms, belong to that program.
27///
28/// This wrapper captures the generated query without parsing or executing it.
29/// Use [`QueryOutput::Captured`] to retrieve it for an [`Adapter`](crate::Adapter).
30/// Input is consumed from its current position and is not rewound on subsequent
31/// executions. Buffering, output routing, cancellation, and current stream-I/O
32/// limitations follow [`crate::programs`].
33///
34/// # Example
35///
36/// Compile a request, then pass the captured query to a dataset adapter:
37///
38/// ```no_run
39/// use asimov_runner::{
40/// Adapter, AdapterOptions, Compiler, CompilerOptions, GraphOutput,
41/// QueryInput, QueryOutput, StreamExt, TextInput,
42/// };
43/// use std::io::Cursor;
44///
45/// # async fn example() -> Result<(), asimov_runner::ExecutorError> {
46/// let mut compiler = Compiler::new(
47/// "asimov-example-compiler",
48/// TextInput::AsyncRead(Box::new(Cursor::new(b"Describe the known cities.".to_vec()))),
49/// QueryOutput::Captured,
50/// CompilerOptions::default(),
51/// );
52/// let query = compiler.execute().await?;
53///
54/// let mut adapter = Adapter::new(
55/// "asimov-example-adapter",
56/// QueryInput::AsyncRead(Box::new(query)),
57/// GraphOutput::Captured,
58/// AdapterOptions::default(),
59/// );
60/// let mut graph = adapter.execute().await?;
61/// while let Some(batch) = graph.next().await {
62/// for bytes in batch?.lines() {
63/// // Process this JSONL graph line.
64/// }
65/// }
66/// # Ok(())
67/// # }
68/// ```
69///
70/// [compiler]: https://asimov-specs.github.io/program-patterns/#compiler
71#[allow(unused)]
72#[derive(Debug)]
73pub struct Compiler {
74 executor: Executor,
75 options: CompilerOptions,
76 input: TextInput,
77 output: QueryOutput,
78}
79
80impl Compiler {
81 /// Configures a compiler without starting it.
82 ///
83 /// Forwards `options.other` as individual arguments in order. The pattern
84 /// defines no standard model or format flags, so none are generated.
85 /// `input` and `output` select stdin and stdout handling; stderr is captured
86 /// for failure diagnostics.
87 ///
88 /// To select a named input file, supply its path through `options.other`
89 /// after any extension options and use [`TextInput::Ignored`]. No standard
90 /// output-file operand is defined. This constructor does not validate
91 /// operands, extension support, or the input's encoding.
92 pub fn new(
93 program: impl AsRef<OsStr>,
94 input: TextInput,
95 output: QueryOutput,
96 options: CompilerOptions,
97 ) -> Self {
98 let mut executor = Executor::new(program);
99 executor
100 .command()
101 .args(&options.other)
102 .stdin(input.as_stdio())
103 .stdout(output.as_stdio())
104 .stderr(Stdio::piped());
105
106 Self {
107 executor,
108 options,
109 input,
110 output,
111 }
112 }
113
114 /// Sends the remaining text input to a new child and returns captured query bytes.
115 ///
116 /// # Errors
117 ///
118 /// Returns an [`ExecutorError`] if spawning, copying input, or waiting fails,
119 /// or if the compiler exits unsuccessfully. Query syntax and UTF-8 validity
120 /// are the external program's responsibility and are not checked here.
121 pub async fn execute(&mut self) -> CompilerResult {
122 let stdout = self
123 .executor
124 .execute_with_io(&mut self.input, &mut self.output)
125 .await?;
126 Ok(stdout)
127 }
128}
129
130impl asimov_patterns::Compiler<Cursor<Vec<u8>>> for Compiler {}
131
132#[async_trait]
133impl asimov_patterns::Execute<Cursor<Vec<u8>>> for Compiler {
134 type Error = ExecutorError;
135
136 async fn execute(&mut self) -> CompilerResult {
137 self.execute().await
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn test_default_invocation() {
147 for options in [
148 CompilerOptions::default(),
149 CompilerOptions::builder().build(),
150 ] {
151 let mut compiler = Compiler::new(
152 "asimov-test-compiler",
153 TextInput::Ignored,
154 QueryOutput::Captured,
155 options,
156 );
157 // The standard compiler invocation has no implicit model or format flags.
158 assert_eq!(compiler.executor.command().as_std().get_args().count(), 0);
159 }
160 }
161
162 #[test]
163 fn test_argument_boundaries_and_order() {
164 let options = CompilerOptions::builder()
165 .other("--dataset")
166 .maybe_other(Some("value with spaces; $HOME"))
167 .maybe_other(None::<&str>)
168 .other("--")
169 .other("-request with spaces.txt")
170 .build();
171 let mut compiler = Compiler::new(
172 "asimov-test-compiler",
173 TextInput::Ignored,
174 QueryOutput::Captured,
175 options,
176 );
177 let args: Vec<_> = compiler.executor.command().as_std().get_args().collect();
178 assert_eq!(
179 args,
180 [
181 "--dataset",
182 "value with spaces; $HOME",
183 "--",
184 "-request with spaces.txt",
185 ]
186 );
187 }
188
189 #[cfg(unix)]
190 #[tokio::test]
191 async fn test_compile_and_pass_query_to_adapter() {
192 use crate::{Adapter, AdapterOptions, GraphOutput, QueryInput, StreamExt};
193
194 async fn compile(
195 compiler: &mut impl asimov_patterns::Compiler<Cursor<Vec<u8>>, Error = ExecutorError>,
196 ) -> CompilerResult {
197 compiler.execute().await
198 }
199
200 // Local fixtures verify transport and composition without an inference provider.
201 let options = CompilerOptions::builder()
202 .other("-c")
203 .other(
204 "test \"$(cat)\" = 'Describe café locations.' || exit 65; \
205 printf '%s\n' 'PREFIX ex: <https://example.com/>' \
206 'CONSTRUCT { ?s ?p ?o } WHERE {' ' ?s ?p ?o' '}'",
207 )
208 .build();
209 let mut compiler = Compiler::new(
210 "/bin/sh",
211 TextInput::AsyncRead(Box::new(Cursor::new(
212 "Describe café locations.\n".as_bytes().to_vec(),
213 ))),
214 QueryOutput::Captured,
215 options,
216 );
217 let query = compile(&mut compiler).await.unwrap();
218 let expected =
219 b"PREFIX ex: <https://example.com/>\nCONSTRUCT { ?s ?p ?o } WHERE {\n ?s ?p ?o\n}\n";
220 assert_eq!(query.position(), 0);
221 assert_eq!(query.get_ref(), expected);
222
223 // The adapter fixture echoes the query it receives, exposing any lost bytes.
224 let mut adapter = Adapter::new(
225 "/bin/cat",
226 QueryInput::AsyncRead(Box::new(query)),
227 GraphOutput::Captured,
228 AdapterOptions::default(),
229 );
230 let mut stream = adapter.execute().await.unwrap();
231 let mut output = Vec::new();
232 while let Some(batch) = stream.next().await {
233 for line in batch.unwrap().lines() {
234 output.extend_from_slice(line);
235 }
236 }
237 assert_eq!(output, expected);
238 }
239
240 #[cfg(unix)]
241 #[tokio::test]
242 async fn test_ignored_output_is_not_captured() {
243 let mut compiler = Compiler::new(
244 "/bin/sh",
245 TextInput::Ignored,
246 QueryOutput::Ignored,
247 CompilerOptions::builder()
248 .other("-c")
249 .other("printf '%s\n' 'CONSTRUCT {} WHERE {}'")
250 .build(),
251 );
252 assert!(compiler.execute().await.unwrap().into_inner().is_empty());
253 }
254
255 #[cfg(unix)]
256 #[tokio::test]
257 async fn test_failure_preserves_diagnostics_instead_of_returning_partial_query() {
258 let mut compiler = Compiler::new(
259 "/bin/sh",
260 TextInput::Ignored,
261 QueryOutput::Captured,
262 CompilerOptions::builder()
263 .other("-c")
264 .other(
265 "printf '%s\n' 'CONSTRUCT {'; \
266 printf '%s\n' 'Unable to compile the request.' >&2; exit 65",
267 )
268 .build(),
269 );
270 match compiler.execute().await {
271 Err(ExecutorError::Failure(error, Some(stderr))) => {
272 assert_eq!(error.code(), Some(65));
273 assert_eq!(stderr, "Unable to compile the request.\n");
274 },
275 result => panic!("expected a compilation failure with diagnostics, got {result:?}"),
276 }
277 }
278}