452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
|
|
use oa_core::{
|
|
Command, language,
|
|
persistence::MigrationReport,
|
|
prepare_command,
|
|
source_files::{self, DiskProject, SourceFileError},
|
|
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
|
|
validation::Severity,
|
|
};
|
|
use serde::Serialize;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub const LIMITS: ProjectLimits = ProjectLimits {
|
|
max_files: 256,
|
|
max_include_depth: 32,
|
|
max_include_edges: 1024,
|
|
model: language::ParseLimits {
|
|
max_source_bytes: 64 * 1024 * 1024,
|
|
max_graph_objects: 100_000,
|
|
max_records: 250_000,
|
|
max_step_references: 500_000,
|
|
},
|
|
};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Operation {
|
|
Check,
|
|
Stabilize,
|
|
RenameAlias { old: String, new: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Location {
|
|
pub path: String,
|
|
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
|
|
pub start: usize,
|
|
pub end: usize,
|
|
pub line: Option<usize>,
|
|
pub column: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Diagnostic {
|
|
pub severity: String,
|
|
pub code: String,
|
|
pub message: String,
|
|
pub coordinate_space: String,
|
|
pub location: Option<Location>,
|
|
pub related: Vec<Location>,
|
|
pub object_id: Option<String>,
|
|
pub field: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Change {
|
|
pub path: String,
|
|
/// Exact captured and proposed UTF-8 text, including line endings.
|
|
pub before: String,
|
|
pub after: String,
|
|
pub diff: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Report {
|
|
pub format_version: u32,
|
|
pub entry: String,
|
|
pub operation: String,
|
|
pub status: String,
|
|
pub exit_code: u8,
|
|
/// True only after a successful changed save. False on failure does not
|
|
/// promise rollback: consult the specific error/recovery outcome.
|
|
pub written: bool,
|
|
pub diagnostics: Vec<Diagnostic>,
|
|
pub changes: Vec<Change>,
|
|
pub identities_to_stabilize: usize,
|
|
pub migration: Option<MigrationReport>,
|
|
pub notes: Vec<String>,
|
|
}
|
|
|
|
impl Report {
|
|
pub fn failure(
|
|
entry: &Path,
|
|
operation: &str,
|
|
exit_code: u8,
|
|
code: &str,
|
|
message: String,
|
|
) -> Self {
|
|
let mut report = Self::empty(entry, operation);
|
|
report.fail(exit_code, code, message);
|
|
report
|
|
}
|
|
|
|
fn empty(entry: &Path, operation: &str) -> Self {
|
|
Self {
|
|
format_version: 1,
|
|
entry: entry.to_string_lossy().into_owned(),
|
|
operation: operation.into(),
|
|
status: "checked".into(),
|
|
exit_code: 0,
|
|
written: false,
|
|
diagnostics: vec![],
|
|
changes: vec![],
|
|
identities_to_stabilize: 0,
|
|
migration: None,
|
|
notes: vec![],
|
|
}
|
|
}
|
|
|
|
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
|
|
self.status = "rejected".into();
|
|
self.exit_code = exit_code;
|
|
self.diagnostics.push(Diagnostic {
|
|
severity: "error".into(),
|
|
code: code.into(),
|
|
message,
|
|
coordinate_space: "operation".into(),
|
|
location: None,
|
|
related: vec![],
|
|
object_id: None,
|
|
field: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
|
|
let d = issue.diagnostic;
|
|
Diagnostic {
|
|
severity: match d.severity {
|
|
Severity::Error => "error",
|
|
Severity::Warning => "warning",
|
|
}
|
|
.into(),
|
|
code: d.code,
|
|
message: d.message,
|
|
coordinate_space: space.into(),
|
|
location: Some(Location {
|
|
path: issue.path,
|
|
start: d.span.start,
|
|
end: d.span.end,
|
|
line: Some(d.line),
|
|
column: Some(d.column),
|
|
}),
|
|
related: issue
|
|
.related
|
|
.into_iter()
|
|
.map(|at| Location {
|
|
path: at.path,
|
|
start: at.span.start,
|
|
end: at.span.end,
|
|
line: None,
|
|
column: None,
|
|
})
|
|
.collect(),
|
|
object_id: d.object_id.map(|id| id.to_string()),
|
|
field: d.field,
|
|
}
|
|
}
|
|
|
|
fn disk_error(report: &mut Report, error: SourceFileError) {
|
|
let code = match &error {
|
|
SourceFileError::Conflict(_) => "external_change",
|
|
SourceFileError::RecoveryRequired(_) => "recovery_required",
|
|
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
|
|
SourceFileError::Path(_) => "unsafe_path",
|
|
SourceFileError::Limit(_) => "source_limit",
|
|
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
|
|
SourceFileError::Invalid(_) => "invalid_save_candidate",
|
|
SourceFileError::Io { .. } => "io_error",
|
|
};
|
|
report.fail(3, code, error.to_string());
|
|
}
|
|
|
|
/// A candidate owns its original expectations and is consumed by saving.
|
|
/// Callers cannot replace its source/model/expectations after preparation.
|
|
pub struct Prepared {
|
|
report: Report,
|
|
snapshot: DiskProject,
|
|
compiled: CompiledSourceProject,
|
|
mutable: bool,
|
|
}
|
|
|
|
impl Prepared {
|
|
pub fn report(&self) -> &Report {
|
|
&self.report
|
|
}
|
|
|
|
pub fn into_report(self) -> Report {
|
|
self.report
|
|
}
|
|
|
|
pub fn write(mut self) -> Report {
|
|
if !self.mutable {
|
|
self.report
|
|
.fail(2, "usage", "check/lint does not accept --write".into());
|
|
return self.report;
|
|
}
|
|
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
|
|
Ok(_) => {
|
|
self.report.written = !self.report.changes.is_empty();
|
|
self.report.status = if self.report.written {
|
|
"written"
|
|
} else {
|
|
"unchanged"
|
|
}
|
|
.into();
|
|
}
|
|
Err(error) => disk_error(&mut self.report, error),
|
|
}
|
|
self.report
|
|
}
|
|
}
|
|
|
|
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
|
|
let name = match operation {
|
|
Operation::Check => "check",
|
|
Operation::Stabilize => "stabilize",
|
|
Operation::RenameAlias { .. } => "rename-alias",
|
|
};
|
|
let mut report = Report::empty(entry, name);
|
|
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
|
|
disk_error(&mut report, error);
|
|
Box::new(report.clone())
|
|
})?;
|
|
let raw = snapshot.reachable_bundle();
|
|
// The loader may include warnings from stabilized compilation. Only its
|
|
// discovery/IO diagnostics are authoritative on the original bytes.
|
|
let mut diagnostics: Vec<_> = snapshot
|
|
.diagnostics
|
|
.iter()
|
|
.filter(|d| {
|
|
matches!(
|
|
d.diagnostic.code.as_str(),
|
|
"pending_save"
|
|
| "source_encoding"
|
|
| "source_limit"
|
|
| "invalid_path"
|
|
| "missing_include"
|
|
| "project_limit"
|
|
)
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
|
|
if !diagnostics.contains(&diagnostic) {
|
|
diagnostics.push(diagnostic);
|
|
}
|
|
}
|
|
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
|
|
matches!(
|
|
d.diagnostic.code.as_str(),
|
|
"pending_save" | "missing_include" | "invalid_path"
|
|
)
|
|
});
|
|
let invalid = diagnostics
|
|
.iter()
|
|
.any(|d| d.diagnostic.severity == Severity::Error);
|
|
report.diagnostics = diagnostics
|
|
.into_iter()
|
|
.map(|d| located(d, "input"))
|
|
.collect();
|
|
if invalid {
|
|
// Do not run declaration analysis or any updater on invalid raw text.
|
|
// Failed lint may also have allocated provisional object identities.
|
|
for diagnostic in &mut report.diagnostics {
|
|
diagnostic.object_id = None;
|
|
}
|
|
report.status = "invalid".into();
|
|
report.exit_code = if disk_refusal { 3 } else { 1 };
|
|
return Err(Box::new(report));
|
|
}
|
|
let explicit_ids: std::collections::BTreeSet<_> = raw
|
|
.files
|
|
.values()
|
|
.flat_map(|text| language::analyze(text).declarations)
|
|
.filter_map(|d| d.id.map(|id| id.to_string()))
|
|
.collect();
|
|
for diagnostic in &mut report.diagnostics {
|
|
if diagnostic
|
|
.object_id
|
|
.as_ref()
|
|
.is_some_and(|id| !explicit_ids.contains(id))
|
|
{
|
|
diagnostic.object_id = None;
|
|
}
|
|
}
|
|
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
|
|
report.status = "invalid".into();
|
|
report.exit_code = 1;
|
|
report
|
|
.diagnostics
|
|
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
|
Box::new(report.clone())
|
|
})?;
|
|
report.identities_to_stabilize = raw
|
|
.files
|
|
.values()
|
|
.map(|text| {
|
|
language::analyze(text)
|
|
.declarations
|
|
.into_iter()
|
|
.filter(|d| d.id.is_none())
|
|
.count()
|
|
})
|
|
.sum();
|
|
report.migration = compiled.decoded.migration.clone();
|
|
let mutable = !matches!(operation, Operation::Check);
|
|
if let Operation::RenameAlias { old, new } = operation {
|
|
let project = &compiled.decoded.project;
|
|
let Some((id, _)) = project
|
|
.identities()
|
|
.into_iter()
|
|
.find(|(_, alias)| *alias == old)
|
|
else {
|
|
report.fail(
|
|
1,
|
|
"unknown_alias",
|
|
format!("No declaration has alias '{old}'."),
|
|
);
|
|
return Err(Box::new(report));
|
|
};
|
|
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
|
|
report.fail(1, "semantic_command_rejected", error.to_string());
|
|
Box::new(report.clone())
|
|
})?;
|
|
compiled = source_project::update_bundle_with_identity_map(
|
|
&compiled,
|
|
&command.project,
|
|
&command.identity_map,
|
|
LIMITS,
|
|
)
|
|
.map_err(|diagnostics| {
|
|
report.status = "invalid".into();
|
|
report.exit_code = 1;
|
|
report
|
|
.diagnostics
|
|
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
|
Box::new(report.clone())
|
|
})?;
|
|
}
|
|
if mutable {
|
|
for (path, after) in &compiled.bundle.files {
|
|
let before = raw.files.get(path).cloned().unwrap_or_default();
|
|
if &before != after {
|
|
report.changes.push(Change {
|
|
path: path.clone(),
|
|
diff: unified_diff(path, &before, after),
|
|
before,
|
|
after: after.clone(),
|
|
});
|
|
}
|
|
}
|
|
report.status = "preview".into();
|
|
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
|
|
}
|
|
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
|
|
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
|
|
Ok(Prepared {
|
|
report,
|
|
snapshot,
|
|
compiled,
|
|
mutable,
|
|
})
|
|
}
|
|
|
|
/// One bounded hunk per file: common prefix/suffix plus three context lines.
|
|
/// Linear in input size; no quadratic LCS allocation for large modules.
|
|
fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
|
let a: Vec<_> = before.split_inclusive('\n').collect();
|
|
let b: Vec<_> = after.split_inclusive('\n').collect();
|
|
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
|
let suffix = a[prefix..]
|
|
.iter()
|
|
.rev()
|
|
.zip(b[prefix..].iter().rev())
|
|
.take_while(|(a, b)| a == b)
|
|
.count();
|
|
let start = prefix.saturating_sub(3);
|
|
let a_end = (a.len() - suffix + 3).min(a.len());
|
|
let b_end = (b.len() - suffix + 3).min(b.len());
|
|
let a_start = if a_end == start { start } else { start + 1 };
|
|
let b_start = if b_end == start { start } else { start + 1 };
|
|
let mut out = format!(
|
|
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
|
|
a_end - start,
|
|
b_end - start
|
|
);
|
|
let mut emit = |prefix: char, line: &str| {
|
|
out.push(prefix);
|
|
out.push_str(line);
|
|
if !line.ends_with('\n') {
|
|
out.push_str("\n\\ No newline at end of file\n");
|
|
}
|
|
};
|
|
for line in &a[start..prefix] {
|
|
emit(' ', line);
|
|
}
|
|
for line in &a[prefix..a.len() - suffix] {
|
|
emit('-', line);
|
|
}
|
|
for line in &b[prefix..b.len() - suffix] {
|
|
emit('+', line);
|
|
}
|
|
for line in &a[a.len() - suffix..a_end] {
|
|
emit(' ', line);
|
|
}
|
|
out
|
|
}
|
|
|
|
pub struct Arguments {
|
|
pub entry: PathBuf,
|
|
pub operation: Operation,
|
|
pub write: bool,
|
|
pub json: bool,
|
|
}
|
|
|
|
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
|
|
|
|
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
|
let mut positional = Vec::new();
|
|
let (mut write, mut json) = (false, false);
|
|
for arg in args {
|
|
if arg == "--write" && !write {
|
|
write = true;
|
|
} else if arg == "--json" && !json {
|
|
json = true;
|
|
} else if arg.to_string_lossy().starts_with('-') {
|
|
return Err(format!(
|
|
"Unknown or repeated option: {}",
|
|
arg.to_string_lossy()
|
|
));
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
|
let operation = match (command, positional.len()) {
|
|
("check" | "lint", 2) if !write => Operation::Check,
|
|
("stabilize", 2) => Operation::Stabilize,
|
|
("rename-alias", 4) => Operation::RenameAlias {
|
|
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
|
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
|
},
|
|
_ => return Err(HELP.into()),
|
|
};
|
|
Ok(Arguments {
|
|
entry: PathBuf::from(positional[1]),
|
|
operation,
|
|
write,
|
|
json,
|
|
})
|
|
}
|