slicec 0.4.0

The Slice parser and other core components for Slice compilers.
Documentation
// Copyright (c) ZeroC, Inc.

use std::collections::HashMap;
use std::fs::File;
use std::io::{Error, ErrorKind, Write};
use std::path::PathBuf;
use std::process::{Child, Command, ExitCode, Stdio};

use clap::Parser;

use slice_codec::decoder::Decoder;
use slice_codec::encoder::Encoder;

use slicec::ast::Ast;
use slicec::compilation_state::CompilationState;
use slicec::diagnostic_emitter::DiagnosticEmitter;
use slicec::diagnostics::Diagnostics;
use slicec::slice_file::SliceFile;
use slicec::slice_options::{DiagnosticFormat, Plugin, SliceOptions};

mod definition_types;
mod slice_file_converter;

/// Attempts to encode a set of parsed Slice files into a byte-buffer.
/// If the encoding succeeds, this returns `Ok` with the encoded bytes,
/// otherwise this returns `Err` with an error describing the failure.
fn encode_generate_code_request(parsed_files: &[slicec::slice_file::SliceFile]) -> Result<Vec<u8>, slice_codec::Error> {
    // Create a buffer to encode into, and an encoder over-top of it.
    let mut encoding_buffer: Vec<u8> = Vec::new();
    let mut slice_encoder = Encoder::from(&mut encoding_buffer);

    // Encode the 'operation name'.
    slice_encoder.encode("generateCode")?;

    // Sort the parsed files into two groups: source files and reference files.
    // We also convert from the AST representation to the Slice representation at this time.
    let mut source_files = Vec::new();
    let mut reference_files = Vec::new();
    for parsed_file in parsed_files {
        // Convert the Slice file from AST representation to Slice representation.
        let converted_file = definition_types::SliceFile::from(parsed_file);
        // Determine whether this is a source or reference file and place it accordingly.
        match parsed_file.is_source {
            true => source_files.push(converted_file),
            false => reference_files.push(converted_file),
        }
    }

    // Encode the Slice-files as 2 sequences; one of source files, and one of reference files.
    slice_encoder.encode(&source_files)?;
    slice_encoder.encode(&reference_files)?;

    // We're done!
    Ok(encoding_buffer)
}

/// Spawns (and starts) a subprocess to run the provided plugin, and writes the provided payload to its 'stdin',
/// followed by any plugin arguments.
fn spawn_plugin_process(plugin: &Plugin, slice_payload: &[u8]) -> std::io::Result<Child> {
    // Spawn a new subprocess and set up pipes for all of its streams.
    let mut subprocess = Command::new(&plugin.path)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;

    // We require that plugins must read the entire payload from 'stdin' before writing to 'stdout' or 'stderr',
    // so there's no concern of deadlock due to the pipe buffer filling up.

    // Write the encoded Slice definitions to the subprocess's 'stdin'.
    let stdin = subprocess.stdin.as_mut().ok_or(ErrorKind::BrokenPipe)?;
    stdin.write_all(slice_payload)?;

    // Encode and write any plugin arguments to the subprocess's 'stdin'.
    let mut arguments_payload = Vec::new();
    let mut slice_encoder = Encoder::from(&mut arguments_payload);
    slice_encoder.encode(definition_types::Options(plugin.args.clone()))?;
    stdin.write_all(&arguments_payload)?;

    // Return a handle to the subprocess so we can wait on it to complete.
    Ok(subprocess)
}

/// Runs the provided subprocess to completion, handling any failures that may occur during its execution.
///
/// If the subprocess completes successfully, this returns `Ok` with its response payload.
/// Otherwise, if the subprocess fails to complete, completed with a non-zero status code, or wrote to 'stderr',
/// this returns an `Err` describing the failure.
fn collect_plugin_output(subprocess: Child) -> std::io::Result<Vec<u8>> {
    // Wait until the subprocess finishes, then retrieve its output.
    let output = subprocess.wait_with_output()?;

    // If the subprocess wrote anything to its 'stderr', we consider this a failure and don't generate any code.
    if !output.stderr.is_empty() {
        // TODO: switch to 'from_utf8_lossy_owned' when stabilized (https://github.com/rust-lang/rust/issues/129436).
        let mut error_string = String::from_utf8_lossy(&output.stderr).into_owned();
        error_string.insert_str(0, "errors reported on 'stderr':\n");
        return Err(Error::other(error_string));
    }

    // Otherwise, check the subprocess's status code to determine success.
    match output.status.code() {
        // If the subprocess exited with a status code of 0, all is good. We return the encoded response from 'stdout'.
        Some(0) => Ok(output.stdout),

        // If the subprocess exited with a non-zero status code, we consider this a failure and don't generate any code.
        Some(code) => Err(Error::other(format!("failed with status code '{code}'"))),

        // If the subprocess did not exit with a status code, it was interrupted; this is also treated as a failure.
        None => Err(Error::from(ErrorKind::Interrupted)),
    }
}

/// Decodes a response from a code-generator plugin.
///
/// If the response could be successfully decoded, this returns the generated files and diagnostics contained in the
/// response, converted to a form `slicec` can utilize. Otherwise this returns an `Err` describing the failure.
fn decode_generator_response(
    response_payload: Vec<u8>,
    ast: &Ast,
    files: &[SliceFile],
) -> std::io::Result<(Vec<definition_types::GeneratedFile>, Diagnostics)> {
    // Decode the generator's response. It consists of 2 sequences, one of generated files and one of diagnostics.
    let mut slice_decoder = Decoder::from(&response_payload);
    let generated_files: Vec<definition_types::GeneratedFile> = slice_decoder.decode()?;
    let generator_diagnostics: Vec<definition_types::Diagnostic> = slice_decoder.decode()?;

    // Take all of the decoded diagnostics from the generator, and convert them into diagnostics that slicec can handle.
    let mut converted_diagnostics = Diagnostics::new();
    for generator_diagnostic in generator_diagnostics {
        slice_file_converter::convert_diagnostic(generator_diagnostic, ast, files, &mut converted_diagnostics);
    }

    Ok((generated_files, converted_diagnostics))
}

/// Attempts to write a generated file to disk.
///
/// If an output directory was specified, the generated file will be written relative to it; Otherwise, the generated
/// file will be written relative to the current working directory.
fn write_generated_file(
    generated_file: &definition_types::GeneratedFile,
    output_dir: &Option<String>,
) -> std::io::Result<()> {
    let generated_file_bytes = generated_file.contents.as_bytes();

    // Compute the output path. If an output directory was specified, prepend it to the generated file's relative path.
    let generated_file_path = match output_dir {
        Some(dir) => PathBuf::from(dir).join(&generated_file.path),
        None => PathBuf::from(&generated_file.path),
    };

    // If the generated file already exists on disk, and is identical to what we want to write,
    // we don't overwrite the file, and instead return immediately.
    if let Ok(current_contents) = std::fs::read(&generated_file_path) {
        if current_contents == generated_file_bytes {
            return Ok(());
        }
    }

    // Write the generated file to disk.
    let mut file = File::create(&generated_file_path)?;
    file.write_all(generated_file_bytes)?;
    Ok(())
}

/// Checks if the path of the provided `generated_file` has already been written to during this compilation.
/// If it has, this returns an `Err` with an error describing the overwriting. Otherwise this returns `Ok`.
/// Note that this function normalizes/canonicalizes the path before checking.
fn check_if_file_is_overwritten<'a>(
    generated_file: &definition_types::GeneratedFile,
    generator: &'a Plugin,
    written_to_paths: &mut HashMap<PathBuf, &'a Plugin>,
) -> Result<(), slicec::diagnostics::Error> {
    // Attempt to canonicalize the path. If that fails for any reason, fallback to using the path string as-is.
    let canonical_path = std::fs::canonicalize(generated_file.path.as_str())
        .unwrap_or_else(|_| PathBuf::from(generated_file.path.clone()));

    // If we've already written to the file's path in this compilation run, return an error.
    if let Some(other_plugin) = written_to_paths.insert(canonical_path, generator) {
        let message = format!(
            "the path '{}' was already written to by '{}', and would be overwritten by '{}'",
            generated_file.path, other_plugin.path, generator.path,
        );
        Err(slicec::diagnostics::Error::Other { message })
    } else {
        Ok(())
    }
}

/// Converts an [`std::io::Error`] that occurred while trying to write a generated file into a
/// [`Diagnostic`](slicec::diagnostics::Diagnostic), and pushes it into the provided [`Diagnostics`] collection.
fn report_file_writing_error(file_path: &String, io_error: std::io::Error, diagnostics: &mut Diagnostics) {
    let diagnostic = slicec::diagnostics::Error::IO {
        action: "write generated file",
        path: file_path.to_owned(),
        error: io_error,
    };
    slicec::diagnostics::Diagnostic::from_error(diagnostic).push_into(diagnostics);
}

/// Converts any [`std::io::Error`]s that occurred while trying to run a plugin into
/// [`Diagnostic`](slicec::diagnostics::Diagnostic)s which can be emitted by the compiler.
fn convert_generator_errors_to_diagnostics(generator: &Plugin, io_error: std::io::Error) -> Diagnostics {
    let mapped_io_error = slicec::diagnostics::Error::IO {
        action: "run code-generator",
        path: generator.path.clone(),
        error: io_error,
    };

    let mut diagnostics = Diagnostics::new();
    slicec::diagnostics::Diagnostic::from_error(mapped_io_error).push_into(&mut diagnostics);
    diagnostics
}

fn main() -> ExitCode {
    // Parse the command-line input.
    let slice_options = SliceOptions::parse();

    // Compile the provided Slice files.
    let mut compilation_state = slicec::compile_from_options(&slice_options);
    let CompilationState {
        ref ast,
        ref mut diagnostics,
        ref files,
    } = compilation_state;

    // Only invoke the plugins if there were no errors in the Slice files.
    if !diagnostics.has_errors() {
        // Encode the request which will be sent to each of the code-generation plugins.
        let encoded_request = match encode_generate_code_request(files) {
            Ok(result) => result,
            Err(error) => {
                eprintln!("Critical error: failed to encode request payload!\n{error:?}");
                return ExitCode::from(79);
            }
        };

        // Start each of the generators in parallel.
        let generators = slice_options.generators.iter();
        let generator_processes = generators
            .map(|generator| (generator, spawn_plugin_process(generator, &encoded_request)))
            .collect::<Vec<_>>();

        // Store which file-paths we've written code to, so we can reject overwrites.
        // Keys are file-paths, and values are the plugins that wrote to the path (for more actionable diagnostics).
        let mut written_to_paths: HashMap<PathBuf, &Plugin> = HashMap::new();

        // Block on each generator process until they're finished. If a generator completes successfully,
        // we get the response payload from it, write any generated files in the payload, and store any diagnostics
        // the generator reported so we can emit them at the end along with all the others.
        for (generator, generator_process) in generator_processes {
            let (generated_files, mut generator_diagnostics) = generator_process
                // 'collect_plugin_output' returns the response payload if the generator ran successfully.
                .and_then(collect_plugin_output)
                // 'decode_generator_response' decodes the payload into a Vec<GeneratedFile> and a Vec<Diagnostic>.
                .and_then(|payload| decode_generator_response(payload, ast, files))
                // Convert any unexpected errors into reportable diagnostics.
                .unwrap_or_else(|err| (Vec::new(), convert_generator_errors_to_diagnostics(generator, err)));

            // If the generator didn't report any errors, write the generated files.
            if !generator_diagnostics.has_errors() {
                for generated_file in &generated_files {
                    // If we've already written to the file's path in this compilation run, report an error.
                    if let Err(error) = check_if_file_is_overwritten(generated_file, generator, &mut written_to_paths) {
                        slicec::diagnostics::Diagnostic::from_error(error).push_into(diagnostics);
                    }

                    // Write the generated file to disk.
                    // If an error occurs while writing the file, report the error as a slicec diagnostic.
                    if let Err(io_error) = write_generated_file(generated_file, &slice_options.output_dir) {
                        report_file_writing_error(&generated_file.path, io_error, &mut generator_diagnostics);
                    }
                }
            }

            // Store generator's diagnostics for later emission, after setting the plugin that reported them.
            for mut generator_diagnostic in generator_diagnostics.into_inner() {
                generator_diagnostic.plugin = Some(generator.path.clone());
                diagnostics.push(generator_diagnostic);
            }
        }
    }

    // Process the diagnostics (filter out allowed lints, and update diagnostic levels as necessary).
    let updated_diagnostics = compilation_state.get_annotated_diagnostics(&slice_options);
    let (warning_count, error_count) = DiagnosticEmitter::get_totals(&updated_diagnostics);

    // Print any diagnostics to the console, along with the total number of warnings and errors emitted.
    let mut stderr = console::Term::stderr();
    let mut emitter = DiagnosticEmitter::new(&mut stderr, &slice_options);
    emitter.emit_diagnostics(&updated_diagnostics).expect("failed to emit");

    // Only emit the summary message if we're writing human-readable output.
    if slice_options.diagnostic_format == DiagnosticFormat::Human {
        DiagnosticEmitter::emit_totals(warning_count, error_count).expect("failed to emit totals");
    }

    // Finished.
    match error_count == 0 {
        true => ExitCode::SUCCESS,
        false => ExitCode::FAILURE,
    }
}