Skip to content
Open
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
2 changes: 2 additions & 0 deletions Cargo.lock

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

40 changes: 40 additions & 0 deletions crates/xlog-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ enum Command {
Run(RunArgs),
Prob(ProbArgs),
Explain(ExplainArgs),
Extract(ResolvedProgramArgs),
Manifest(ResolvedProgramArgs),
Repl(ReplArgs),
Watch(WatchArgs),
}
Expand Down Expand Up @@ -137,6 +139,17 @@ struct ExplainArgs {
module_path: Vec<PathBuf>,
}

#[derive(Parser)]
struct ResolvedProgramArgs {
source: PathBuf,
/// Root used to emit portable source-relative module paths
#[arg(long)]
source_root: PathBuf,
/// Additional directories to search for modules (colon-separated)
#[arg(long, value_delimiter = ':')]
module_path: Vec<PathBuf>,
}

#[derive(Parser)]
struct ReplArgs {
/// Additional directories to search for modules (colon-separated)
Expand Down Expand Up @@ -200,11 +213,38 @@ fn main() -> Result<()> {
Command::Run(args) => run_deterministic(args),
Command::Prob(args) => run_probabilistic(args),
Command::Explain(args) => explain(args),
Command::Extract(args) => extract(args),
Command::Manifest(args) => manifest(args),
Command::Repl(args) => repl(args),
Command::Watch(args) => watch(args),
}
}

fn extract(args: ResolvedProgramArgs) -> Result<()> {
let resolver = load_modules(&args.source, args.module_path)
.map_err(|error| XlogError::Execution(format!("Module resolution failed: {error}")))?;
let extraction = resolver
.resolved_program_extraction(&args.source_root)
.map_err(|error| XlogError::Execution(format!("Program extraction failed: {error}")))?;
let json = serde_json::to_string_pretty(&extraction).map_err(|error| {
XlogError::Execution(format!("Extraction serialization failed: {error}"))
})?;
println!("{json}");
Ok(())
}

fn manifest(args: ResolvedProgramArgs) -> Result<()> {
let resolver = load_modules(&args.source, args.module_path)
.map_err(|error| XlogError::Execution(format!("Module resolution failed: {error}")))?;
let manifest = resolver
.resolved_program_manifest(&args.source_root)
.map_err(|error| XlogError::Execution(format!("Manifest construction failed: {error}")))?;
let json = serde_json::to_string_pretty(&manifest)
.map_err(|error| XlogError::Execution(format!("Manifest serialization failed: {error}")))?;
println!("{json}");
Ok(())
}

fn explain(args: ExplainArgs) -> Result<()> {
let source = std::fs::read_to_string(&args.source).map_err(|e| {
XlogError::Execution(format!("Failed to read {}: {}", args.source.display(), e))
Expand Down
106 changes: 106 additions & 0 deletions crates/xlog-cli/tests/extraction_cli_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use assert_cmd::cargo::cargo_bin_cmd;
use tempfile::TempDir;

#[test]
fn xlog_extract_emits_the_resolved_executable_program() {
let fixture = TempDir::new().expect("create fixture directory");
std::fs::write(
fixture.path().join("support.xlog"),
"pred support(symbol).\nsupport(ok).\n",
)
.expect("write support module");
let entry = fixture.path().join("main.xlog");
std::fs::write(
&entry,
"use support.\npred answer(symbol).\nanswer(X) :- support(X).\n?- answer(X).\n",
)
.expect("write entry module");

let first = cargo_bin_cmd!("xlog")
.args([
"extract",
"--source-root",
fixture.path().to_str().expect("UTF-8 source root"),
entry.to_str().expect("UTF-8 entry path"),
])
.output()
.expect("run xlog extract");
assert!(
first.status.success(),
"xlog extract failed: {}",
String::from_utf8_lossy(&first.stderr)
);
let second = cargo_bin_cmd!("xlog")
.args([
"extract",
"--source-root",
fixture.path().to_str().expect("UTF-8 source root"),
entry.to_str().expect("UTF-8 entry path"),
])
.output()
.expect("rerun xlog extract");
assert_eq!(first.stdout, second.stdout);

let stdout = String::from_utf8(first.stdout).expect("UTF-8 extraction output");
let payload: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON extraction");
assert_eq!(
payload["schema_version"],
"xlog.resolved-program-extraction.v1"
);
assert_eq!(
payload["source_manifest"]["modules"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(
payload["source_manifest"]["imports"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(
payload["executable_program"]["rules"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(
payload["executable_program"]["queries"][0]["goal"]["relation_id"],
"relation:answer/1"
);
assert!(!stdout.contains(fixture.path().to_str().unwrap()));
}

#[test]
fn xlog_extract_rejects_a_resolved_source_outside_the_declared_root() {
let fixture = TempDir::new().expect("create fixture directory");
let external = TempDir::new().expect("create external module directory");
std::fs::write(external.path().join("support.xlog"), "support(ok).\n")
.expect("write external support module");
let entry = fixture.path().join("main.xlog");
std::fs::write(&entry, "use support.\n").expect("write entry module");

let output = cargo_bin_cmd!("xlog")
.args([
"extract",
"--source-root",
fixture.path().to_str().expect("UTF-8 source root"),
"--module-path",
external.path().to_str().expect("UTF-8 module path"),
entry.to_str().expect("UTF-8 entry path"),
])
.output()
.expect("run xlog extract");

assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(
String::from_utf8_lossy(&output.stderr).contains("outside source root"),
"unexpected stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
2 changes: 2 additions & 0 deletions crates/xlog-logic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ xlog-ir.workspace = true
xlog-stats.workspace = true
pest.workspace = true
pest_derive.workspace = true
serde = { version = "1", features = ["derive"] }
sha2 = "0.11"

[dev-dependencies]
tempfile = "3"
Expand Down
32 changes: 28 additions & 4 deletions crates/xlog-logic/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,14 @@ pub struct Program {
pub directives: Directives,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ProgramMergeReport {
pub domains: Vec<usize>,
pub predicates: Vec<usize>,
pub functions: Vec<usize>,
pub rules: Vec<usize>,
}

impl Program {
/// Create an empty program.
pub fn new() -> Self {
Expand Down Expand Up @@ -1115,8 +1123,18 @@ impl Program {
other: &Program,
imported_items: Option<&std::collections::HashSet<String>>,
) {
self.merge_from_with_report(other, imported_items);
}

pub(crate) fn merge_from_with_report(
&mut self,
other: &Program,
imported_items: Option<&std::collections::HashSet<String>>,
) -> ProgramMergeReport {
use std::collections::HashSet;

let mut report = ProgramMergeReport::default();

// Track which predicates are private in the source
let private_preds: HashSet<&str> = other
.predicates
Expand All @@ -1133,7 +1151,7 @@ impl Program {
.collect();

// Merge predicate declarations (only public ones)
for pred in &other.predicates {
for (source_index, pred) in other.predicates.iter().enumerate() {
if pred.is_private {
continue;
}
Expand All @@ -1146,11 +1164,12 @@ impl Program {
// Avoid duplicate declarations
if !self.predicates.iter().any(|p| p.name == pred.name) {
self.predicates.push(pred.clone());
report.predicates.push(source_index);
}
}

// Merge functions (only public ones)
for func in &other.functions {
for (source_index, func) in other.functions.iter().enumerate() {
if func.is_private {
continue;
}
Expand All @@ -1162,11 +1181,12 @@ impl Program {
// Avoid duplicate functions
if !self.functions.iter().any(|f| f.name == func.name) {
self.functions.push(func.clone());
report.functions.push(source_index);
}
}

// Merge rules (facts and rules for public predicates)
for rule in &other.rules {
for (source_index, rule) in other.rules.iter().enumerate() {
// Skip if the head predicate is private
if private_preds.contains(rule.head.predicate.as_str()) {
continue;
Expand All @@ -1179,15 +1199,19 @@ impl Program {
}
if !self.rules.iter().any(|existing| existing == rule) {
self.rules.push(rule.clone());
report.rules.push(source_index);
}
}

// Merge domains
for domain in &other.domains {
for (source_index, domain) in other.domains.iter().enumerate() {
if !self.domains.iter().any(|d| d.name == domain.name) {
self.domains.push(domain.clone());
report.domains.push(source_index);
}
}

report
}
}

Expand Down
8 changes: 7 additions & 1 deletion crates/xlog-logic/src/incremental_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ fn split_statements(source: &str) -> Vec<StatementUnit> {
continue;
}

if ch == '.' && !is_decimal_point(source, idx) {
if ch == '.' && !is_decimal_point(source, idx) && !is_univ_operator_dot(source, idx) {
push_statement(source, &line_starts, start, idx + ch.len_utf8(), &mut out);
start = idx + ch.len_utf8();
}
Expand All @@ -297,6 +297,12 @@ fn split_statements(source: &str) -> Vec<StatementUnit> {
out
}

fn is_univ_operator_dot(source: &str, index: usize) -> bool {
let bytes = source.as_bytes();
(index > 0 && index + 1 < bytes.len() && bytes[index - 1] == b'=' && bytes[index + 1] == b'.')
|| (index > 1 && bytes[index - 2] == b'=' && bytes[index - 1] == b'.')
}

fn push_statement(
source: &str,
line_starts: &[usize],
Expand Down
Loading
Loading