Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ thiserror = { workspace = true }
# Used only by the S0 characterization suite to synthesise `.mid` fixtures
# directly, independent of griff's own export path.
midly = { workspace = true }
# Same reasoning one format over: the SWG-4A-10 suite writes its Guitar Pro
# fixture with the parser crate's own serializer, so the bytes under test come
# from an encoder independent of griff's importer. Already in the tree via
# `griff-core`'s default `gp` feature — this adds no third-party code, only
# test-time access to it.
guitarpro = { version = "0.4", default-features = false }

[lints]
workspace = true
1 change: 1 addition & 0 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
pub mod curation_fs;
pub mod generation_input;
pub mod rhythm_pattern;
pub mod swang_dump;

pub use griff_core::generation_input::primary_voice_note_count;
47 changes: 47 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
use griff_cli::generation_input::{load_corpus_material, CorpusMaterial, GenerationInputError};
use griff_cli::primary_voice_note_count;
use griff_cli::rhythm_pattern;
use griff_cli::swang_dump::dump_score;
use griff_core::generation_input::{ranked_candidates, GenerationAsk, RankedSet};
use griff_core::{
boundary,
Expand All @@ -33,6 +34,7 @@ use griff_core::{
split, structure, syncopation, technique, unfold,
};
use griff_pattern::NodePath;
use griff_swang::exact::ExactWriteError;
use griff_swang::{eval, syntax};

/// griff — guitar riff engine.
Expand Down Expand Up @@ -299,6 +301,15 @@ enum SwangCommand {
#[arg(value_name = "INPUT")]
input: PathBuf,
},
/// Import a MIDI or Guitar Pro file and print its canonical Swang
/// level-2 exact text to stdout. Import warnings and diagnostics go to
/// stderr; the document goes to stdout and nowhere else.
Dump {
/// Path to the MIDI (`.mid`) or Guitar Pro
/// (`.gp3`/`.gp4`/`.gp5`/`.gpx`/`.gp`) file.
#[arg(value_name = "INPUT")]
input: PathBuf,
},
}

fn run() -> Result<(), CliError> {
Expand Down Expand Up @@ -379,6 +390,7 @@ fn run() -> Result<(), CliError> {
SwangCommand::Fmt { input } => cmd_swang_fmt(&input),
SwangCommand::Expand { input } => cmd_swang_expand(&input),
SwangCommand::Build { input } => cmd_swang_build(&input),
SwangCommand::Dump { input } => cmd_swang_dump(&input),
},
}
}
Expand Down Expand Up @@ -413,6 +425,31 @@ fn cmd_swang_fmt(path: &Path) -> Result<(), CliError> {
Ok(())
}

/// `griff swang dump`: import through the existing adapters and print the
/// canonical level-2 exact text (SWG-4A-10).
///
/// Two surfaces, and the split is the whole contract. The document goes to
/// stdout and only to stdout; import warnings and diagnostics go to stderr,
/// one rendered entry per warning and in the loss report's order. An entry
/// is not promised to occupy one physical line — an `Other` message may hold
/// a line break, and nothing here escapes it.
/// A warning stays in the document's `loss` block whether or not stderr also
/// mentioned it — `griff_cli::swang_dump` renders the two independently, and
/// this function only decides which stream each reaches.
///
/// Both are computed before either is written, so a refusal prints nothing
/// at all rather than a truncated document.
fn cmd_swang_dump(path: &Path) -> Result<(), CliError> {
let data = fs::read(path)?;
let score = import::import_score_auto(&data)?;
let dumped = dump_score(&score)?;
for warning in &dumped.warnings {
eprintln!("warning: {warning}");
}
print!("{}", dumped.document);
Ok(())
}

/// `griff swang expand`: the pattern pipeline up to `map_rhythm`, printing
/// the canonical expansion artifact to stdout (spec §3.5's CLI contract).
/// The shared evaluator drives the same compiler the transport does, so law
Expand Down Expand Up @@ -2238,6 +2275,9 @@ enum CliError {
/// A Swang syntax diagnostic, already rendered against its script:
/// `error[SWG____] (<path>:<line>:<col>): <message>`.
Swang(String),
/// The imported score is outside the exact writer's domain, so there is
/// no document to print (`docs/swang/exact-score-text.md` §3).
ExactWrite(ExactWriteError),
}

impl fmt::Display for CliError {
Expand All @@ -2256,6 +2296,7 @@ impl fmt::Display for CliError {
Self::Complement(e) => write!(f, "complement error: {e:?}"),
Self::Pattern(d) => write!(f, "{d}"),
Self::Swang(rendered) => write!(f, "{rendered}"),
Self::ExactWrite(e) => write!(f, "exact-text error: {e}"),
}
}
}
Expand All @@ -2266,6 +2307,12 @@ impl From<IoError> for CliError {
}
}

impl From<ExactWriteError> for CliError {
fn from(e: ExactWriteError) -> Self {
Self::ExactWrite(e)
}
}

impl From<MidiError> for CliError {
fn from(e: MidiError) -> Self {
Self::Midi(e)
Expand Down
83 changes: 83 additions & 0 deletions cli/src/swang_dump.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! SWG-4A-10: composing the two surfaces of `griff swang dump`.
//!
//! The command has exactly one interesting decision in it, and this module
//! is where it lives: a canonical `Score` produces **two** independent
//! renderings, and neither may consume the other.
//!
//! - The **document** is the canonical level-2 exact text, straight from
//! [`griff_swang::exact::write_score`]. It goes to stdout, whole or not at
//! all. Nothing here sorts, tidies, annotates, or re-spells it: the census
//! (`docs/swang/exact-score-text.md`) already decided every byte, and a
//! second opinion at the CLI would be a second formatter.
//! - The **warnings** are a human rendering of `score.loss` for stderr: one
//! rendered entry per `ImportWarning`, in the report's vector order.
//!
//! One *entry*, deliberately, and not one physical line. `ImportWarning`
//! includes `Other(String)`, whose message is unrestricted and may contain a
//! line break — SWG-4A-05 pinned that case in the exact text precisely
//! because the data can hold one. The exact writer escapes it, because its
//! grammar says a warning is one physical line. Nothing obliges the terminal
//! rendering to agree, and SWG-4A-10 introduces no escaping or sanitization
//! policy of its own: promising a line count here would be inventing a
//! contract to make a sentence true.
//!
//! The loss report appears on both surfaces, and that is deliberate rather
//! than redundant. `Score.loss` is a canonical fact the exact text is
//! obliged to carry (§2.8); the stderr rendering is a courtesy to whoever is
//! watching the terminal. Dropping the `loss` block because a human already
//! saw the warning would make the document depend on who was looking at it.

use griff_core::score::{ImportWarning, Score};
use griff_swang::exact::{write_score, ExactWriteError};

/// The two surfaces of one dump, both built before either is written.
///
/// Returning them together is what makes "no partial document" structural
/// rather than a discipline: a refusal produces no `DumpOutput` at all, so
/// there is no half-formed value for a caller to print.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DumpOutput {
/// The canonical level-2 document, for stdout.
pub document: String,
/// One human-facing rendering per `ImportWarning`, in `LossReport`
/// vector order, for stderr. An entry is not promised to be a single
/// physical line: an `Other` message may carry its own line breaks, and
/// this surface adds no escaping policy to prevent that.
pub warnings: Vec<String>,
}

/// Renders `score` as the document and the warning entries that accompany
/// it, one entry per `ImportWarning`, in `LossReport` order.
///
/// # Errors
/// [`ExactWriteError`] when the score is outside the exact writer's domain.
/// The writer produces no bytes in that case and neither does this.
pub fn dump_score(score: &Score) -> Result<DumpOutput, ExactWriteError> {
let document = write_score(score)?;
let warnings = score.loss.warnings.iter().map(describe_warning).collect();
Ok(DumpOutput { document, warnings })
}

/// One import warning, in a sentence — verbatim for `Other`, whose message
/// is unrestricted and is neither escaped nor reflowed here.
///
/// The exact text spells the same facts in its own frozen grammar; this is
/// the terminal rendering and is never the source of truth for any of them.
fn describe_warning(warning: &ImportWarning) -> String {
match warning {
ImportWarning::TrackNameInvalidUtf8 { track_index } => {
format!("track {track_index} has a name that is not valid UTF-8; it was dropped")
}
ImportWarning::SmpteTimingUnsupported => {
"the source used SMPTE timing, which griff does not support".to_owned()
}
ImportWarning::TempoApproximated {
bar_index,
nearest_micros,
} => format!(
"the tempo of bar {bar_index} has no exact microsecond form; \
it was approximated to {nearest_micros}"
),
ImportWarning::Other(message) => message.clone(),
}
}
Loading
Loading