Skip to main content

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}