Skip to main content

latexml_core/common/
locator.rs

1use std::{fmt, fmt::Write as _};
2
3use crate::{
4  common::{
5    arena::{self, SymStr},
6    object::Object,
7  },
8  util::pathname,
9};
10
11// TODO: This will require a large refactor, but
12// switching the source from an owned String to a &str reference
13// could provide a noticeable performance (and memory allocation) boost
14// (and especially if we also start adding locators to tokens)
15// my current thoughts are that we can have the core/gullet own the sources of all mouths
16// so that we can borrow them with the lifetime of the main convert_document loop...
17// that's harder than it sounds, I've already tried unsuccessfully with the Token contents,
18// but the mouth sources should be easier to manage.
19// definitely something that can be tried after test milestone is achieved.
20
21#[derive(Copy, Clone, PartialEq, Eq)]
22pub struct Locator {
23  pub source:      SymStr,
24  pub from_line:   u32,
25  pub to_line:     u32,
26  pub from_column: u32,
27  pub to_column:   u32,
28}
29
30impl Default for Locator {
31  fn default() -> Self {
32    Locator {
33      source:      arena::pin(file!()),
34      from_line:   line!(),
35      to_line:     line!(),
36      from_column: column!(),
37      to_column:   column!(),
38    }
39  }
40}
41
42// elide Locator debugging until we get to implementing them faithfully
43impl fmt::Debug for Locator {
44  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[...]") }
45}
46
47impl fmt::Display for Locator {
48  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
49    write!(f, "{}", self.get_short_source(""))?;
50    if self.from_line > 0 {
51      write!(f, "; line {}", self.from_line)?;
52      if self.from_column > 0 {
53        write!(f, " col {}", self.from_column)?;
54      }
55    }
56    if self.to_line > 0 {
57      write!(f, " - line {}", self.to_line)?;
58      if self.to_column > 0 {
59        write!(f, " col {}", self.to_column)?;
60      }
61    }
62    Ok(())
63  }
64}
65
66impl Locator {
67  pub fn new<S: AsRef<str>>(
68    source: S,
69    from_line: u32,
70    from_column: u32,
71    to_line: u32,
72    to_column: u32,
73  ) -> Self {
74    Locator {
75      source: arena::pin(source.as_ref()),
76      from_line,
77      to_line,
78      from_column,
79      to_column,
80    }
81  }
82
83  /// [`Locator::new`] with an already-interned source. `Locator::new` re-pins
84  /// the source string (an interner hash probe over the whole path) on every
85  /// call, which is real cost on paths that build locators per token / per
86  /// conditional — a `Mouth` pins its source ONCE at construction and builds
87  /// locators from the cached symbol. Same parameter order as `new`.
88  pub fn from_sym(
89    source: SymStr,
90    from_line: u32,
91    from_column: u32,
92    to_line: u32,
93    to_column: u32,
94  ) -> Self {
95    Locator {
96      source,
97      from_line,
98      to_line,
99      from_column,
100      to_column,
101    }
102  }
103
104  /// creates a new locator range from a given start and end
105  pub fn new_range(from: Locator, to: Locator) -> Option<Locator> {
106    // make sure that either parameters are defined
107    // bail if we have different sources
108    if from.source != to.source {
109      return None;
110    }
111    // the end coordinates depend on
112    let (to_line, to_column) = if to.is_range() {
113      (to.to_line, to.to_column)
114    } else {
115      (to.from_line, to.from_column)
116    };
117    Some(Locator {
118      source: from.source,
119      from_line: from.from_line,
120      from_column: from.from_column,
121      to_line,
122      to_column,
123    })
124  }
125
126  pub fn is_range(&self) -> bool { self.to_line > 0 || self.to_column > 0 }
127
128  pub fn get_short_source(&self, string_source: &str) -> String {
129    arena::with(self.source, |source| {
130      if source.is_empty() {
131        if string_source.is_empty() {
132          "String".to_string()
133        } else {
134          string_source.to_string()
135        }
136      } else if source.contains(':') {
137        let (base, ext) = pathname::url_split(source);
138        s!("{}.{}", base, ext)
139      } else {
140        let (_path, base, _ext) = pathname::split(source);
141        base
142      }
143    })
144  }
145  pub fn get_source(&self) -> SymStr { self.source }
146
147  pub fn get_from_locator(&self) -> Locator {
148    Locator {
149      source: self.source,
150      from_line: self.from_line,
151      from_column: self.from_column,
152      ..Locator::default()
153    }
154  }
155
156  pub fn get_to_locator(&self) -> Locator {
157    Locator {
158      source: self.source,
159      from_line: self.to_line,
160      from_column: self.to_column,
161      ..Locator::default()
162    }
163  }
164}
165impl Object for Locator {
166  fn stringify(&self) -> String {
167    let mut loc = arena::to_string(self.source);
168    if loc.is_empty() {
169      loc = "Anonymous String".to_string()
170    };
171    let range_from = if self.is_range() { " from" } else { "" };
172    if self.from_line > 0 {
173      write!(loc, ";{} line {}", range_from, self.from_line).ok();
174      if self.from_column > 0 {
175        write!(loc, " col {}", self.from_column).ok();
176      }
177    }
178    if self.to_line > 0 {
179      write!(loc, " to line {}", self.to_line).ok();
180      if self.to_column > 0 {
181        write!(loc, " col {}", self.to_column).ok();
182      }
183    }
184    loc
185  }
186
187  /// getting the locator of a locator should return itself
188  fn get_locator(&self) -> Option<Locator> { Some(*self) }
189}
190
191impl Locator {
192  pub fn to_attribute(&self) -> String {
193    let mut loc = self.get_short_source("anonymous_string") + "#text";
194    if self.is_range() {
195      loc.push_str("range(from='");
196      // if self.from_line > 0 {
197      loc.push_str(&self.from_line.to_string());
198      // }
199      // if self.from_column > 0 {
200      loc.push(';');
201      loc.push_str(&self.from_column.to_string());
202      // }
203      loc.push_str(",to='");
204      // if self.to_line > 0 {
205      loc.push_str(&self.to_line.to_string());
206      // }
207      // if self.to_column > 0 {
208      loc.push(';');
209      loc.push_str(&self.to_column.to_string());
210    } else {
211      loc.push_str("point('");
212      // if self.from_line > 0 {
213      loc.push_str(&self.from_line.to_string());
214      // }
215      // if self.from_column > 0 {
216      loc.push(';');
217      loc.push_str(&self.from_column.to_string());
218    }
219    // }
220    loc.push_str(")'");
221    loc
222  }
223
224  /// Serialise as a compact, web-facing `data-sourcepos` value:
225  /// `tag:line:col-tag:line:col` for a range, `tag:line:col` for a point.
226  ///
227  /// This is the source-map feature's serialiser (issues #47/#92) — the
228  /// brief, sibling-aligned form documented in `docs/performance/SOURCE_PROVENANCE.md`
229  /// §0/§0.1, deliberately *not* the XPointer `to_attribute()` above
230  /// (which has zero web-platform support and is latent in the port).
231  ///
232  /// `tag` is the source's index in the document-level `sources` table
233  /// (Source-Map-v3 style) — never an inlined path, so the markup stays
234  /// tiny and is anonymisable. The file is first-class in *each* endpoint;
235  /// a `Locator` currently carries a single `source` (so both endpoints
236  /// share `tag`), but the endpoint-complete form future-proofs a
237  /// per-endpoint-source `Locator`.
238  pub fn to_sourcepos(&self, tag: u32) -> String {
239    if self.is_range() {
240      format!(
241        "{tag}:{}:{}-{tag}:{}:{}",
242        self.from_line, self.from_column, self.to_line, self.to_column
243      )
244    } else {
245      format!("{tag}:{}:{}", self.from_line, self.from_column)
246    }
247  }
248}
249
250#[cfg(test)]
251mod tests {
252  use super::*;
253
254  #[test]
255  fn new_builds_from_parts() {
256    let l = Locator::new("source.tex", 1, 2, 3, 4);
257    assert_eq!(l.from_line, 1);
258    assert_eq!(l.from_column, 2);
259    assert_eq!(l.to_line, 3);
260    assert_eq!(l.to_column, 4);
261  }
262
263  #[test]
264  fn is_range_false_for_point() {
265    // Point locator: to_line=0 AND to_column=0.
266    let l = Locator::new("source", 1, 1, 0, 0);
267    assert!(!l.is_range());
268  }
269
270  #[test]
271  fn is_range_true_when_any_to_set() {
272    let with_to_line = Locator::new("source", 1, 1, 5, 0);
273    let with_to_col = Locator::new("source", 1, 1, 0, 5);
274    assert!(with_to_line.is_range());
275    assert!(with_to_col.is_range());
276  }
277
278  #[test]
279  fn new_range_requires_same_source() {
280    let a = Locator::new("a.tex", 1, 1, 0, 0);
281    let b = Locator::new("b.tex", 5, 5, 0, 0);
282    assert!(
283      Locator::new_range(a, b).is_none(),
284      "different sources must return None"
285    );
286  }
287
288  #[test]
289  fn new_range_takes_from_and_to() {
290    let a = Locator::new("same.tex", 1, 2, 0, 0);
291    let b = Locator::new("same.tex", 5, 6, 0, 0);
292    let r = Locator::new_range(a, b).unwrap();
293    assert_eq!(r.from_line, 1);
294    assert_eq!(r.from_column, 2);
295    // When `to` is a point (not a range), its from_line/col are used
296    // as the to_line/col.
297    assert_eq!(r.to_line, 5);
298    assert_eq!(r.to_column, 6);
299  }
300
301  #[test]
302  fn new_range_propagates_to_range() {
303    // When `to` is itself a range, use its to_line/col (not its from_*)
304    let a = Locator::new("same.tex", 1, 1, 0, 0);
305    let b = Locator::new("same.tex", 5, 5, 10, 20);
306    let r = Locator::new_range(a, b).unwrap();
307    assert_eq!(r.from_line, 1);
308    assert_eq!(r.to_line, 10);
309    assert_eq!(r.to_column, 20);
310  }
311
312  #[test]
313  fn display_includes_source_and_line() {
314    let l = Locator::new("paper.tex", 42, 10, 0, 0);
315    let s = format!("{l}");
316    assert!(s.contains("paper"), "got {s:?}");
317    assert!(s.contains("line 42"), "got {s:?}");
318  }
319
320  #[test]
321  fn stringify_empty_source_is_anonymous_string() {
322    let l = Locator::new("", 1, 1, 0, 0);
323    let s = l.stringify();
324    assert!(s.contains("Anonymous String"), "got {s:?}");
325  }
326
327  #[test]
328  fn get_short_source_fallback_when_empty() {
329    let l = Locator::new("", 0, 0, 0, 0);
330    assert_eq!(l.get_short_source(""), "String");
331    assert_eq!(l.get_short_source("inline"), "inline");
332  }
333
334  #[test]
335  fn get_from_and_to_locators_preserve_source() {
336    let l = Locator::new("paper.tex", 1, 2, 3, 4);
337    let from = l.get_from_locator();
338    let to = l.get_to_locator();
339    assert_eq!(from.source, l.source);
340    assert_eq!(to.source, l.source);
341    // from_locator captures from_*, to_locator captures to_*.
342    assert_eq!(from.from_line, 1);
343    assert_eq!(from.from_column, 2);
344    assert_eq!(to.from_line, 3);
345    assert_eq!(to.from_column, 4);
346  }
347
348  #[test]
349  fn object_get_locator_returns_self() {
350    let l = Locator::new("paper.tex", 1, 2, 3, 4);
351    let got = l.get_locator();
352    assert_eq!(got, Some(l));
353  }
354
355  #[test]
356  fn to_sourcepos_point() {
357    // Point locator (to_line==0 && to_column==0): no `-` separator.
358    let l = Locator::new("paper.tex", 12, 1, 0, 0);
359    assert_eq!(l.to_sourcepos(0), "0:12:1");
360  }
361
362  #[test]
363  fn to_sourcepos_range() {
364    let l = Locator::new("paper.tex", 12, 1, 12, 240);
365    assert_eq!(l.to_sourcepos(0), "0:12:1-0:12:240");
366  }
367
368  #[test]
369  fn to_sourcepos_tag_is_per_endpoint() {
370    // The integer file tag is first-class in *each* endpoint.
371    let l = Locator::new("paper.tex", 3, 5, 7, 9);
372    assert_eq!(l.to_sourcepos(2), "2:3:5-2:7:9");
373  }
374}