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 crates/tikzarium-extensions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
pub mod evidence;
pub mod primitive_edit;
pub mod primitives;
pub mod project_disconnect;
pub mod project_imports;

use serde::Deserialize;
Expand Down
205 changes: 205 additions & 0 deletions crates/tikzarium-extensions/src/project_disconnect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! C/N2 reversible *disconnection* of selected literal TikZ libraries.
//!
//! No TeX macro expansion or package removal is attempted. We only remove one
//! definitely active, directly authored \usetikzlibrary declaration (or one
//! token in its simple comma list). Ambiguity is a refusal, not a source edit.

use crate::{
primitive_edit::SourceTarget,
primitives::{Evidence, PrimitiveError, SourcePatch},
project_imports::{ImportKind, inspect, literal_names, uncommented},
};
use std::ops::Range;

const BEGIN_DOCUMENT: &str = "\\begin{document}";
const SUPPORTED_LIBRARIES: [&str; 4] = ["arrows.meta", "positioning", "calc", "fit"];

/// A proposed, source-visible deletion. B owns confirmation and Undo UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisconnectPlan {
pub patch: SourcePatch,
pub anchor: Range<usize>,
pub requested_library: String,
/// Other library names retained on a shared literal declaration line.
pub remaining_on_line: Vec<String>,
}

impl DisconnectPlan {
/// Recheck the begin-document anchor and existing SourcePatch revision/bytes.
/// Returns an inverse SourcePatch to be recorded by the document's Undo stack.
pub fn apply(
&self,
source: &mut String,
revision: &mut u64,
) -> Result<SourcePatch, PrimitiveError> {
if source.get(self.anchor.clone()) != Some(BEGIN_DOCUMENT) {
return Err(PrimitiveError::SourceConflict);
}
self.patch.apply(source, revision)
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DisconnectDecision {
AlreadyAbsent,
Proposed(DisconnectPlan),
Refused { reason: &'static str },
}

struct Candidate {
range: Range<usize>,
remaining_on_line: Vec<String>,
}

/// Produce precisely one deletion inside a *complete* literal physical line.
/// Offsets and ranges count UTF-8 bytes, matching the shared SourcePatch API.
fn candidate_for_line(
raw: &str,
offset: usize,
requested: &str,
) -> Result<Option<Candidate>, &'static str> {
let line = raw.trim_end_matches(['\r', '\n']);
let active = uncommented(line);
let Some(names) = literal_names(active.trim(), "\\usetikzlibrary", false) else {
return Ok(None);
};
if !names.iter().any(|item| item == requested) {
return Ok(None);
}
// Never remove a note belonging to this declaration or move its comment.
if active.len() != line.len() {
return Err("library declaration has an inline comment; edit source manually");
}

let open = line
.find('{')
.ok_or("library declaration has no safe literal brace span")?;
let close = line[open + 1..]
.find('}')
.map(|index| index + open + 1)
.ok_or("library declaration has no safe closing brace")?;

let mut entries: Vec<(String, Range<usize>)> = Vec::new();
let mut cursor = open + 1;
for field in line[open + 1..close].split(',') {
let literal = field.trim();
let leading = field.len() - field.trim_start().len();
let start = cursor + leading;
entries.push((literal.to_owned(), start..start + literal.len()));
cursor += field.len() + 1;
}
if entries.iter().map(|(s, _)| s).ne(names.iter()) {
return Err("library list differs from the inspected literal declaration");
}
let hits: Vec<usize> = entries
.iter()
.enumerate()
.filter_map(|(index, (name, _))| (name == requested).then_some(index))
.collect();
if hits.len() != 1 {
return Err("duplicate library names require a manual source edit");
}
let index = hits[0];
let range = if entries.len() == 1 {
// Entire declaration-only physical line: remove its own line ending.
offset..offset + raw.len()
} else if index == 0 {
// Delete first token and its following comma and spaces.
offset + open + 1..offset + entries[1].1.start
} else {
// Delete preceding comma and this token, including its trailing
// whitespace. Keep the NEXT comma and all surviving names unchanged.
let right = if index + 1 < entries.len() {
let between = &line[entries[index].1.end..entries[index + 1].1.start];
let next_comma = between
.find(',')
.ok_or("library list has no safe separator")?;
entries[index].1.end + next_comma
} else {
close
};
offset + entries[index - 1].1.end..offset + right
};
Ok(Some(Candidate {
range,
remaining_on_line: entries
.into_iter()
.filter_map(|(name, _)| (name != requested).then_some(name))
.collect(),
}))
}

/// Plan one literal, revision-checked library disconnect without mutation.
///
/// A documented library used through macros/conditionals, repeated across
/// declarations, or carrying a same-line comment is intentionally refused.
/// This is not TeX package uninstallation. No source rewrite or compilation.
#[must_use]
pub fn plan_library_disconnect(
source: &str,
target: SourceTarget,
name: &str,
) -> DisconnectDecision {
if !SUPPORTED_LIBRARIES.contains(&name) {
return DisconnectDecision::Refused {
reason: "library has no N2 disconnect adapter; use source",
};
}
let project = inspect(source);
if let Some(reason) = project.uncertain_reason {
return DisconnectDecision::Refused { reason };
}
if Some(&target.range) != project.begin_document.as_ref() {
return DisconnectDecision::Refused {
reason: "source anchor range differs from the inspected preamble",
};
}
match project.status(ImportKind::TikzLibrary, name) {
Evidence::Absent => return DisconnectDecision::AlreadyAbsent,
Evidence::Unknown => {
return DisconnectDecision::Refused {
reason: "cannot prove whether the library is loaded",
};
}
Evidence::Confirmed => {}
}

let mut candidate = None;
let mut offset = 0usize;
// The source slice stops at the byte offset of the confirmed begin anchor.
// A trailing indent from that line has no declaration and is not edited.
for raw in source[..target.range.start].split_inclusive('\n') {
match candidate_for_line(raw, offset, name) {
Ok(Some(found)) if candidate.is_none() => candidate = Some(found),
Ok(Some(_)) => {
return DisconnectDecision::Refused {
reason: "multiple declarations load this library; edit source manually",
};
}
Err(reason) => return DisconnectDecision::Refused { reason },
Ok(None) => {}
}
offset += raw.len();
}
let Some(candidate) = candidate else {
return DisconnectDecision::Refused {
reason: "loaded library has no safely removable literal declaration",
};
};
let Some(expected_old) = source.get(candidate.range.clone()) else {
return DisconnectDecision::Refused {
reason: "source byte range is not a valid UTF-8 boundary",
};
};
DisconnectDecision::Proposed(DisconnectPlan {
patch: SourcePatch {
range: candidate.range,
expected_old: expected_old.to_owned(),
replacement: String::new(),
source_revision: target.revision,
},
anchor: target.range,
requested_library: name.to_owned(),
remaining_on_line: candidate.remaining_on_line,
})
}
92 changes: 71 additions & 21 deletions crates/tikzarium-extensions/src/project_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ pub struct ProjectImports {
}

impl ProjectImports {
/// Confirmed means an unambiguous literal preamble declaration, NOT installed.
/// Absent means no such declaration in a safely recognized preamble.
/// UNKNOWN is mandatory for macro/conditional/indirect or malformed imports.
/// Confirmed means a direct literal declaration in a trusted preamble
/// prefix, NOT installation or a TeX execution result. Unknown commands
/// *after* that declaration cannot erase the textual positive evidence.
/// Absent requires a fully recognized preamble; opaque macros or imports
/// encountered earlier prevent later declarations being called confirmed.
pub fn status(&self, kind: ImportKind, name: &str) -> Evidence {
if self.uncertain_reason.is_some() || !self.class_declared || self.begin_document.is_none()
{
if !self.class_declared || self.begin_document.is_none() {
return Evidence::Unknown;
}
let names = match kind {
Expand All @@ -46,6 +47,8 @@ impl ProjectImports {
};
if names.contains(name) {
Evidence::Confirmed
} else if self.uncertain_reason.is_some() {
Evidence::Unknown
} else {
Evidence::Absent
}
Expand All @@ -68,7 +71,7 @@ fn first_reason(slot: &mut Option<&'static str>, reason: &'static str) {
}
}

fn uncommented(line: &str) -> &str {
pub(crate) fn uncommented(line: &str) -> &str {
let mut slashes = 0usize;
for (index, ch) in line.char_indices() {
if ch == '%' && slashes.is_multiple_of(2) {
Expand All @@ -83,7 +86,11 @@ fn uncommented(line: &str) -> &str {
line
}

fn literal_names(line: &str, command: &str, options_allowed: bool) -> Option<Vec<String>> {
pub(crate) fn literal_names(
line: &str,
command: &str,
options_allowed: bool,
) -> Option<Vec<String>> {
let rest = line.strip_prefix(command)?;
if !rest
.chars()
Expand Down Expand Up @@ -133,6 +140,26 @@ fn literal_names(line: &str, command: &str, options_allowed: bool) -> Option<Vec
Some(names)
}

/// Only flat, literal fontspec/unicode-math font choices are import-neutral.
/// Options, TeX commands, nested braces and other opaque forms remain UNKNOWN.
/// This does not test font installation or override the XeLaTeX compiler.
fn literal_flat_font_assignment(line: &str, command: &str) -> bool {
let Some(rest) = line.strip_prefix(command) else {
return false;
};
let Some(font) = rest
.trim_start()
.strip_prefix('{')
.and_then(|content| content.strip_suffix('}'))
else {
return false;
};
!font.trim().is_empty()
&& font
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b" ._+-".contains(&b))
}

/// Recognize only complete, literal preamble lines. Never expand arbitrary macros.
/// Text after begin(document) is left entirely opaque to this scanner.
#[must_use]
Expand All @@ -156,6 +183,10 @@ pub fn inspect(source: &str) -> ProjectImports {
);
}
let mut offset = 0usize;
// Any opaque/conditional command before an import can hide its meaning.
// Keep direct literal declarations observed *before* uncertainty, but
// never report a later declaration as loaded through guessed control flow.
let mut literal_prefix_trusted = true;
for raw in source.split_inclusive('\n') {
if result.begin_document.is_some() {
break;
Expand Down Expand Up @@ -184,27 +215,39 @@ pub fn inspect(source: &str) -> ProjectImports {
&mut result.uncertain_reason,
"begin-document line is not a standalone literal",
);
literal_prefix_trusted = false;
} else if trimmed.starts_with("\\documentclass") {
match literal_names(trimmed, "\\documentclass", true) {
Some(names) if names.len() == 1 && !result.class_declared => {
result.class_declared = true;
}
_ => first_reason(
&mut result.uncertain_reason,
"opaque or duplicate documentclass declaration",
),
_ => {
first_reason(
&mut result.uncertain_reason,
"opaque or duplicate documentclass declaration",
);
literal_prefix_trusted = false;
}
}
} else if trimmed.starts_with("\\pagestyle")
&& literal_names(trimmed, "\\pagestyle", false).is_some()
{
// A literal page-style setting cannot inject package imports.
} else if literal_flat_font_assignment(trimmed, "\\setmainfont")
|| literal_flat_font_assignment(trimmed, "\\setmathfont")
{
// These exact forms only select fonts; they do not import TikZ.
} else if trimmed.starts_with("\\usepackage") {
match literal_names(trimmed, "\\usepackage", true) {
Some(names) => result.packages.extend(names),
None => first_reason(
&mut result.uncertain_reason,
"opaque usepackage declaration",
),
Some(names) if literal_prefix_trusted => result.packages.extend(names),
Some(_) => {}
None => {
first_reason(
&mut result.uncertain_reason,
"opaque usepackage declaration",
);
literal_prefix_trusted = false;
}
}
} else if trimmed.starts_with("\\usetikzlibrary") {
match literal_names(trimmed, "\\usetikzlibrary", false) {
Expand All @@ -214,13 +257,19 @@ pub fn inspect(source: &str) -> ProjectImports {
&mut result.uncertain_reason,
"usetikzlibrary precedes explicit tikz package",
);
literal_prefix_trusted = false;
}
result.libraries.extend(names);
if literal_prefix_trusted {
result.libraries.extend(names);
}
}
None => {
first_reason(
&mut result.uncertain_reason,
"opaque usetikzlibrary declaration",
);
literal_prefix_trusted = false;
}
None => first_reason(
&mut result.uncertain_reason,
"opaque usetikzlibrary declaration",
),
}
} else {
// An unknown preamble macro can hide imports or alter their meaning.
Expand All @@ -229,6 +278,7 @@ pub fn inspect(source: &str) -> ProjectImports {
&mut result.uncertain_reason,
"opaque preamble command or conditional",
);
literal_prefix_trusted = false;
}
offset += raw.len();
}
Expand Down
Loading
Loading