pebaryan Claude Opus 4.7 commited on
Commit
bf81e27
·
1 Parent(s): 1b38360

Resolve \input{}/\include{} and discover inline bibliographies

Browse files

Multi-file papers now load as one document: resolve_includes() inlines
\input{} / \include{} recursively (cycle-safe, tolerant of missing
targets) and produces a per-line map. Paragraph/Section/Finding carry
an origin file so findings point at the real source a user edits, not
the top-level main.tex.

Papers that ship their bibliography inline as \begin{thebibliography}
now auto-populate BibEntry records from \bibitem keys when no .bib file
is declared, so the citation checker stops emitting false MISSING
findings on that style. Bib-less runs get a single explicit warning
instead of silent degradation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

src/qalmsw/bib/__init__.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from qalmsw.bib.parser import BibEntry, parse_bib_file, parse_bib_text
2
 
3
- __all__ = ["BibEntry", "parse_bib_file", "parse_bib_text"]
 
1
+ from qalmsw.bib.inline import extract_inline_bibitems
2
  from qalmsw.bib.parser import BibEntry, parse_bib_file, parse_bib_text
3
 
4
+ __all__ = ["BibEntry", "extract_inline_bibitems", "parse_bib_file", "parse_bib_text"]
src/qalmsw/bib/inline.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract ``\\bibitem{}`` entries from inline ``\\begin{thebibliography}`` blocks.
2
+
3
+ Many papers ship a pre-formatted bibliography inside the .tex body instead of a
4
+ separate .bib file. We scan those blocks into ``BibEntry`` objects so the citation
5
+ checker can still cross-check ``\\cite`` keys without the user having to point at a
6
+ .bib file by hand.
7
+
8
+ Only the entry key + source line are reliable. We take a best-effort stab at the
9
+ title (the first "quoted" or ``\\textit/\\emph``-wrapped chunk after the author list),
10
+ but the citation checker doesn't need it; the claims checker does, and it tolerates
11
+ empty titles by degrading to an ``info`` finding.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from qalmsw.bib.parser import BibEntry
19
+ from qalmsw.parse.includes import LineMapEntry
20
+
21
+ _THEBIB_BLOCK_RE = re.compile(
22
+ r"\\begin\{thebibliography\}.*?\\end\{thebibliography\}",
23
+ re.DOTALL,
24
+ )
25
+ _BIBITEM_RE = re.compile(
26
+ r"\\bibitem\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}",
27
+ )
28
+ _TITLE_QUOTED_RE = re.compile(r"``([^']+?)''|\"([^\"]+?)\"")
29
+ _TITLE_MACRO_RE = re.compile(r"\\(?:textit|emph|textbf)\s*\{([^}]+)\}")
30
+
31
+
32
+ def extract_inline_bibitems(
33
+ source: str,
34
+ *,
35
+ line_map: list[LineMapEntry] | None = None,
36
+ default_file: Path | None = None,
37
+ ) -> list[BibEntry]:
38
+ """Return a ``BibEntry`` per ``\\bibitem`` found inside a ``thebibliography`` block.
39
+
40
+ When ``line_map`` is provided, each entry's ``file`` and ``line`` are translated
41
+ back to the file that declared them (useful when the bibliography lives in a
42
+ ``\\input``-ed file).
43
+ """
44
+ entries: list[BibEntry] = []
45
+
46
+ def _origin(combined_line: int) -> tuple[Path, int]:
47
+ if line_map is not None:
48
+ idx = combined_line - 1
49
+ if 0 <= idx < len(line_map):
50
+ e = line_map[idx]
51
+ return e.file, e.line
52
+ return (default_file or Path("<source>")), combined_line
53
+
54
+ for block in _THEBIB_BLOCK_RE.finditer(source):
55
+ block_text = block.group(0)
56
+ block_start = block.start()
57
+ # Find bibitems and the text that follows each up to the next bibitem.
58
+ items = list(_BIBITEM_RE.finditer(block_text))
59
+ for i, match in enumerate(items):
60
+ key = match.group(1).strip()
61
+ if not key:
62
+ continue
63
+ combined_line = source.count("\n", 0, block_start + match.start()) + 1
64
+ file, line = _origin(combined_line)
65
+ body_end = items[i + 1].start() if i + 1 < len(items) else len(block_text)
66
+ body = block_text[match.end():body_end]
67
+ title = _guess_title(body)
68
+ entries.append(
69
+ BibEntry(
70
+ key=key,
71
+ entry_type="bibitem",
72
+ file=file,
73
+ line=line,
74
+ title=title,
75
+ )
76
+ )
77
+ return entries
78
+
79
+
80
+ def _guess_title(body: str) -> str:
81
+ """Best-effort title guess from a \\bibitem body.
82
+
83
+ Look for a ``...''-quoted or \\textit/\\emph/\\textbf-wrapped run first; fall back
84
+ to empty. We don't try to be clever — getting the title wrong is fine (claims
85
+ degrades gracefully), getting the key wrong would be a bug.
86
+ """
87
+ m = _TITLE_QUOTED_RE.search(body)
88
+ if m:
89
+ return (m.group(1) or m.group(2) or "").strip()
90
+ m = _TITLE_MACRO_RE.search(body)
91
+ if m:
92
+ return m.group(1).strip()
93
+ return ""
src/qalmsw/checkers/claims.py CHANGED
@@ -169,4 +169,10 @@ class ClaimsChecker:
169
 
170
 
171
  def _finding(para: Paragraph, severity: Severity, message: str) -> Finding:
172
- return Finding(checker="claims", severity=severity, line=para.start_line, message=message)
 
 
 
 
 
 
 
169
 
170
 
171
  def _finding(para: Paragraph, severity: Severity, message: str) -> Finding:
172
+ return Finding(
173
+ checker="claims",
174
+ severity=severity,
175
+ line=para.start_line,
176
+ message=message,
177
+ file=str(para.file) if para.file else None,
178
+ )
src/qalmsw/checkers/grammar.py CHANGED
@@ -70,6 +70,7 @@ def _to_finding(raw: dict, para: Paragraph, checker: str) -> Finding:
70
  message=raw.get("message", "").strip(),
71
  suggestion=(raw.get("suggestion") or None),
72
  excerpt=excerpt or None,
 
73
  )
74
 
75
 
 
70
  message=raw.get("message", "").strip(),
71
  suggestion=(raw.get("suggestion") or None),
72
  excerpt=excerpt or None,
73
+ file=str(para.file) if para.file else None,
74
  )
75
 
76
 
src/qalmsw/checkers/reviewer.py CHANGED
@@ -56,7 +56,11 @@ class ReviewerChecker:
56
  self._concurrency = concurrency
57
 
58
  def check(self, doc: Document) -> list[Finding]:
59
- sections = [s for s in parse_sections(doc.source) if s.text.strip()]
 
 
 
 
60
  results = ordered_parallel_map(
61
  lambda s: self._llm.complete_json(_SYSTEM_PROMPT, self._render_user_prompt(s)),
62
  sections,
@@ -92,4 +96,5 @@ def _to_finding(raw: dict, section: Section, checker: str) -> Finding:
92
  line=section.start_line,
93
  message=prefix + body,
94
  suggestion=(raw.get("suggestion") or None),
 
95
  )
 
56
  self._concurrency = concurrency
57
 
58
  def check(self, doc: Document) -> list[Finding]:
59
+ sections = [
60
+ s
61
+ for s in parse_sections(doc.source, line_map=doc.line_map, default_file=doc.path)
62
+ if s.text.strip()
63
+ ]
64
  results = ordered_parallel_map(
65
  lambda s: self._llm.complete_json(_SYSTEM_PROMPT, self._render_user_prompt(s)),
66
  sections,
 
96
  line=section.start_line,
97
  message=prefix + body,
98
  suggestion=(raw.get("suggestion") or None),
99
+ file=str(section.file) if section.file else None,
100
  )
src/qalmsw/cli.py CHANGED
@@ -7,7 +7,7 @@ import typer
7
  from rich.console import Console
8
 
9
  from qalmsw import __version__
10
- from qalmsw.bib import BibEntry, parse_bib_file
11
  from qalmsw.checkers import (
12
  Checker,
13
  CitationChecker,
@@ -66,6 +66,18 @@ def check(
66
  bib_entries = _load_bib_entries(bib_paths)
67
  if bib_paths:
68
  console.print(f"[dim]{len(bib_entries)} bib entries from {len(bib_paths)} file(s)[/]")
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  checkers: list[Checker] = [CitationChecker(bib_entries)]
71
  if not skip_grammar or not skip_reviewer or enable_claims:
 
7
  from rich.console import Console
8
 
9
  from qalmsw import __version__
10
+ from qalmsw.bib import BibEntry, extract_inline_bibitems, parse_bib_file
11
  from qalmsw.checkers import (
12
  Checker,
13
  CitationChecker,
 
66
  bib_entries = _load_bib_entries(bib_paths)
67
  if bib_paths:
68
  console.print(f"[dim]{len(bib_entries)} bib entries from {len(bib_paths)} file(s)[/]")
69
+ if not bib_entries:
70
+ inline = extract_inline_bibitems(
71
+ doc.source, line_map=doc.line_map, default_file=doc.path
72
+ )
73
+ if inline:
74
+ bib_entries = inline
75
+ console.print(f"[dim]{len(inline)} bib entries from inline \\begin{{thebibliography}}[/]")
76
+ else:
77
+ console.print(
78
+ "[yellow]warning[/]: no bib entries found (no --bib, no \\bibliography{}, "
79
+ "no inline \\begin{thebibliography}); citation checks will be limited."
80
+ )
81
 
82
  checkers: list[Checker] = [CitationChecker(bib_entries)]
83
  if not skip_grammar or not skip_reviewer or enable_claims:
src/qalmsw/document.py CHANGED
@@ -3,13 +3,17 @@
3
  `Document` bundles the .tex path, full source, and parsed paragraphs so individual
4
  checkers don't each re-parse the file and can choose which level of detail to consume
5
  (raw source for citation scanning, paragraphs for grammar, etc.).
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
- from dataclasses import dataclass
10
  from pathlib import Path
11
 
12
- from qalmsw.parse import Paragraph, parse_paragraphs
13
 
14
 
15
  @dataclass(frozen=True)
@@ -17,8 +21,10 @@ class Document:
17
  path: Path
18
  source: str
19
  paragraphs: list[Paragraph]
 
20
 
21
  @classmethod
22
  def load(cls, path: Path) -> Document:
23
- source = path.read_text(encoding="utf-8")
24
- return cls(path=path, source=source, paragraphs=parse_paragraphs(source))
 
 
3
  `Document` bundles the .tex path, full source, and parsed paragraphs so individual
4
  checkers don't each re-parse the file and can choose which level of detail to consume
5
  (raw source for citation scanning, paragraphs for grammar, etc.).
6
+
7
+ ``Document.source`` is the *combined* source — ``\\input{}`` / ``\\include{}`` bodies
8
+ inlined. ``Document.line_map`` lets code translate a line in ``source`` back to its
9
+ original file and line number, so findings keep pointing at the file the author edits.
10
  """
11
  from __future__ import annotations
12
 
13
+ from dataclasses import dataclass, field
14
  from pathlib import Path
15
 
16
+ from qalmsw.parse import LineMapEntry, Paragraph, parse_paragraphs, resolve_includes
17
 
18
 
19
  @dataclass(frozen=True)
 
21
  path: Path
22
  source: str
23
  paragraphs: list[Paragraph]
24
+ line_map: list[LineMapEntry] = field(default_factory=list)
25
 
26
  @classmethod
27
  def load(cls, path: Path) -> Document:
28
+ source, line_map = resolve_includes(path)
29
+ paragraphs = parse_paragraphs(source, line_map=line_map, default_file=path)
30
+ return cls(path=path, source=source, paragraphs=paragraphs, line_map=line_map)
src/qalmsw/parse/__init__.py CHANGED
@@ -1,15 +1,18 @@
1
  from qalmsw.parse.citations import CitationRef, scan_bib_resources, scan_citations
 
2
  from qalmsw.parse.sections import Section, parse_sections
3
  from qalmsw.parse.tex import Paragraph, extract_body, has_prose, parse_paragraphs
4
 
5
  __all__ = [
6
  "CitationRef",
 
7
  "Paragraph",
8
  "Section",
9
  "extract_body",
10
  "has_prose",
11
  "parse_paragraphs",
12
  "parse_sections",
 
13
  "scan_bib_resources",
14
  "scan_citations",
15
  ]
 
1
  from qalmsw.parse.citations import CitationRef, scan_bib_resources, scan_citations
2
+ from qalmsw.parse.includes import LineMapEntry, resolve_includes
3
  from qalmsw.parse.sections import Section, parse_sections
4
  from qalmsw.parse.tex import Paragraph, extract_body, has_prose, parse_paragraphs
5
 
6
  __all__ = [
7
  "CitationRef",
8
+ "LineMapEntry",
9
  "Paragraph",
10
  "Section",
11
  "extract_body",
12
  "has_prose",
13
  "parse_paragraphs",
14
  "parse_sections",
15
+ "resolve_includes",
16
  "scan_bib_resources",
17
  "scan_citations",
18
  ]
src/qalmsw/parse/includes.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recursive ``\\input{}`` / ``\\include{}`` resolution.
2
+
3
+ LaTeX papers commonly split source across files — ``main.tex`` pulls in
4
+ ``sections/intro.tex`` via ``\\input{}``. We inline those contents so downstream
5
+ parsing (paragraphs, sections) sees one contiguous body, and we carry a per-line
6
+ ``LineMap`` so findings can still point at the original file + line.
7
+
8
+ The resolver is conservative:
9
+
10
+ - Only ``\\input`` and ``\\include`` are followed.
11
+ - ``\\input`` inside a ``%`` comment is ignored (we strip comments first).
12
+ - Missing include targets are replaced with empty content (so a single missing
13
+ file doesn't abort the run); we keep a record in ``LineMapEntry`` that points
14
+ back at the parent so downstream code has somewhere to anchor errors.
15
+ - Cycle detection: a file currently on the include stack is not re-expanded.
16
+
17
+ The relative-path convention matches what ``pdflatex`` does: includes are resolved
18
+ relative to the directory of the file that declared them.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ _INCLUDE_RE = re.compile(r"\\(?:input|include)\s*\{([^}]+)\}")
27
+ _COMMENT_RE = re.compile(r"(?<!\\)%.*")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LineMapEntry:
32
+ file: Path
33
+ line: int # 1-indexed inside `file`
34
+
35
+
36
+ def _resolve_target(base_dir: Path, name: str) -> Path:
37
+ name = name.strip()
38
+ candidate = Path(name)
39
+ if not candidate.is_absolute():
40
+ candidate = base_dir / candidate
41
+ if candidate.suffix.lower() != ".tex":
42
+ with_tex = candidate.with_suffix(candidate.suffix + ".tex")
43
+ if with_tex.exists():
44
+ return with_tex
45
+ bare = candidate.parent / (candidate.name + ".tex")
46
+ if bare.exists():
47
+ return bare
48
+ return candidate
49
+
50
+
51
+ def _expand(path: Path, stack: tuple[Path, ...]) -> tuple[list[str], list[LineMapEntry]]:
52
+ """Return ``(lines, map)`` for ``path`` with includes inlined.
53
+
54
+ ``lines`` is a list of lines (no trailing newlines). ``map[i]`` tells you which
55
+ (file, line) produced ``lines[i]``.
56
+ """
57
+ if path in stack:
58
+ # Cycle: return the include as a literal so the check is visible, but don't recurse.
59
+ return ([f"% qalmsw: cycle detected, not expanding {path}"], [LineMapEntry(path, 1)])
60
+ try:
61
+ source = path.read_text(encoding="utf-8")
62
+ except OSError:
63
+ return ([f"% qalmsw: could not read {path}"], [LineMapEntry(path, 1)])
64
+
65
+ out_lines: list[str] = []
66
+ out_map: list[LineMapEntry] = []
67
+ for i, raw_line in enumerate(source.splitlines()):
68
+ lineno = i + 1
69
+ # Strip the comment part to detect \input but keep the original line intact otherwise.
70
+ uncommented = _COMMENT_RE.sub("", raw_line)
71
+ match = _INCLUDE_RE.search(uncommented)
72
+ if match is None:
73
+ out_lines.append(raw_line)
74
+ out_map.append(LineMapEntry(path, lineno))
75
+ continue
76
+ target = _resolve_target(path.parent, match.group(1))
77
+ child_lines, child_map = _expand(target, stack + (path,))
78
+ out_lines.extend(child_lines)
79
+ out_map.extend(child_map)
80
+ return out_lines, out_map
81
+
82
+
83
+ def resolve_includes(path: Path) -> tuple[str, list[LineMapEntry]]:
84
+ """Return ``(combined_source, line_map)`` for ``path`` with includes inlined.
85
+
86
+ ``line_map[i]`` describes line ``i + 1`` of the combined source: which file it came
87
+ from and the 1-indexed line number inside that file.
88
+ """
89
+ lines, line_map = _expand(path, stack=())
90
+ combined = "\n".join(lines)
91
+ if combined:
92
+ combined += "\n"
93
+ return combined, line_map
src/qalmsw/parse/sections.py CHANGED
@@ -2,13 +2,17 @@
2
 
3
  Used by the reviewer checker, which wants coarse section-level chunks rather than
4
  individual paragraphs. If the document has no `\\section{}`, the whole body is returned
5
- as a single untitled section.
 
 
6
  """
7
  from __future__ import annotations
8
 
9
  import re
10
  from dataclasses import dataclass
 
11
 
 
12
  from qalmsw.parse.tex import _COMMENT_RE, extract_body
13
 
14
  _SECTION_RE = re.compile(r"\\section\*?\s*\{([^}]*)\}")
@@ -20,32 +24,52 @@ class Section:
20
  text: str
21
  start_line: int
22
  end_line: int
 
23
 
24
 
25
- def parse_sections(source: str) -> list[Section]:
 
 
 
 
 
26
  body, body_start_line = extract_body(source)
27
  body = _COMMENT_RE.sub("", body)
28
 
 
 
 
 
 
 
 
 
 
29
  matches = list(_SECTION_RE.finditer(body))
30
  if not matches:
31
  total_lines = body.count("\n")
 
 
32
  return [
33
  Section(
34
  title="",
35
  text=body.strip(),
36
- start_line=body_start_line,
37
- end_line=body_start_line + total_lines,
 
38
  )
39
  ]
40
 
41
  sections: list[Section] = []
42
  for i, match in enumerate(matches):
43
  title = match.group(1).strip()
44
- start_line = body_start_line + body.count("\n", 0, match.start())
45
  end_offset = matches[i + 1].start() if i + 1 < len(matches) else len(body)
46
- end_line = body_start_line + body.count("\n", 0, end_offset)
47
  text = body[match.start() : end_offset].strip()
 
 
48
  sections.append(
49
- Section(title=title, text=text, start_line=start_line, end_line=end_line)
50
  )
51
  return sections
 
2
 
3
  Used by the reviewer checker, which wants coarse section-level chunks rather than
4
  individual paragraphs. If the document has no `\\section{}`, the whole body is returned
5
+ as a single untitled section. When a ``line_map`` is provided, each section's ``file``
6
+ and line numbers are translated back to the file the ``\\section{}`` command was
7
+ declared in (e.g. ``sections/intro.tex`` when pulled in via ``\\input``).
8
  """
9
  from __future__ import annotations
10
 
11
  import re
12
  from dataclasses import dataclass
13
+ from pathlib import Path
14
 
15
+ from qalmsw.parse.includes import LineMapEntry
16
  from qalmsw.parse.tex import _COMMENT_RE, extract_body
17
 
18
  _SECTION_RE = re.compile(r"\\section\*?\s*\{([^}]*)\}")
 
24
  text: str
25
  start_line: int
26
  end_line: int
27
+ file: Path | None = None
28
 
29
 
30
+ def parse_sections(
31
+ source: str,
32
+ *,
33
+ line_map: list[LineMapEntry] | None = None,
34
+ default_file: Path | None = None,
35
+ ) -> list[Section]:
36
  body, body_start_line = extract_body(source)
37
  body = _COMMENT_RE.sub("", body)
38
 
39
+ def _origin(combined_line: int) -> tuple[Path | None, int]:
40
+ if line_map is None:
41
+ return default_file, combined_line
42
+ idx = combined_line - 1
43
+ if 0 <= idx < len(line_map):
44
+ entry = line_map[idx]
45
+ return entry.file, entry.line
46
+ return default_file, combined_line
47
+
48
  matches = list(_SECTION_RE.finditer(body))
49
  if not matches:
50
  total_lines = body.count("\n")
51
+ file, start = _origin(body_start_line)
52
+ _, end = _origin(body_start_line + total_lines)
53
  return [
54
  Section(
55
  title="",
56
  text=body.strip(),
57
+ start_line=start,
58
+ end_line=end,
59
+ file=file,
60
  )
61
  ]
62
 
63
  sections: list[Section] = []
64
  for i, match in enumerate(matches):
65
  title = match.group(1).strip()
66
+ start_combined = body_start_line + body.count("\n", 0, match.start())
67
  end_offset = matches[i + 1].start() if i + 1 < len(matches) else len(body)
68
+ end_combined = body_start_line + body.count("\n", 0, end_offset)
69
  text = body[match.start() : end_offset].strip()
70
+ file, start = _origin(start_combined)
71
+ _, end = _origin(end_combined)
72
  sections.append(
73
+ Section(title=title, text=text, start_line=start, end_line=end, file=file)
74
  )
75
  return sections
src/qalmsw/parse/tex.py CHANGED
@@ -2,12 +2,18 @@
2
 
3
  The contract other modules rely on: `parse_paragraphs` returns `Paragraph` objects whose
4
  `start_line`/`end_line` are 1-indexed against the *original* file, so Findings can point
5
- back at the real source location.
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
  import re
10
  from dataclasses import dataclass
 
 
 
11
 
12
  _COMMENT_RE = re.compile(r"(?<!\\)%.*")
13
  _BEGIN_DOC_RE = re.compile(r"\\begin\{document\}")
@@ -22,6 +28,7 @@ class Paragraph:
22
  text: str
23
  start_line: int
24
  end_line: int
 
25
 
26
 
27
  def _strip_comments(source: str) -> str:
@@ -51,8 +58,21 @@ def extract_body(source: str) -> tuple[str, int]:
51
  return source[offset:end_offset], start_line
52
 
53
 
54
- def parse_paragraphs(source: str) -> list[Paragraph]:
55
- """Split the document body into blank-line-separated paragraphs."""
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  body, body_start_line = extract_body(source)
57
  stripped = _strip_comments(body)
58
  lines = stripped.splitlines()
@@ -61,12 +81,25 @@ def parse_paragraphs(source: str) -> list[Paragraph]:
61
  buf: list[str] = []
62
  buf_start: int | None = None
63
 
 
 
 
 
 
 
 
 
 
64
  def flush(end_line: int) -> None:
65
  if not buf:
66
  return
67
  text = "\n".join(buf).strip()
68
  if text:
69
- paragraphs.append(Paragraph(text=text, start_line=buf_start, end_line=end_line))
 
 
 
 
70
  buf.clear()
71
 
72
  for i, line in enumerate(lines):
 
2
 
3
  The contract other modules rely on: `parse_paragraphs` returns `Paragraph` objects whose
4
  `start_line`/`end_line` are 1-indexed against the *original* file, so Findings can point
5
+ back at the real source location. When a ``line_map`` is provided (from
6
+ ``resolve_includes``), each paragraph's ``file`` and line numbers are translated to the
7
+ file the content actually came from — so a finding inside ``sections/intro.tex`` renders
8
+ with that path, not the top-level ``main.tex``.
9
  """
10
  from __future__ import annotations
11
 
12
  import re
13
  from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from qalmsw.parse.includes import LineMapEntry
17
 
18
  _COMMENT_RE = re.compile(r"(?<!\\)%.*")
19
  _BEGIN_DOC_RE = re.compile(r"\\begin\{document\}")
 
28
  text: str
29
  start_line: int
30
  end_line: int
31
+ file: Path | None = None
32
 
33
 
34
  def _strip_comments(source: str) -> str:
 
58
  return source[offset:end_offset], start_line
59
 
60
 
61
+ def parse_paragraphs(
62
+ source: str,
63
+ *,
64
+ line_map: list[LineMapEntry] | None = None,
65
+ default_file: Path | None = None,
66
+ ) -> list[Paragraph]:
67
+ """Split the document body into blank-line-separated paragraphs.
68
+
69
+ If ``line_map`` is provided (as produced by ``resolve_includes``), each paragraph
70
+ is tagged with the file it originated from and its line numbers are translated
71
+ into that file's numbering. Paragraphs are attributed to the file their first
72
+ line belongs to; if a paragraph straddles an ``\\input{}`` boundary, the tail
73
+ lines' original file is discarded (rare in practice; ``\\input{}`` usually sits
74
+ on its own line, producing a paragraph break).
75
+ """
76
  body, body_start_line = extract_body(source)
77
  stripped = _strip_comments(body)
78
  lines = stripped.splitlines()
 
81
  buf: list[str] = []
82
  buf_start: int | None = None
83
 
84
+ def _origin(combined_line: int) -> tuple[Path | None, int]:
85
+ if line_map is None:
86
+ return default_file, combined_line
87
+ idx = combined_line - 1
88
+ if 0 <= idx < len(line_map):
89
+ entry = line_map[idx]
90
+ return entry.file, entry.line
91
+ return default_file, combined_line
92
+
93
  def flush(end_line: int) -> None:
94
  if not buf:
95
  return
96
  text = "\n".join(buf).strip()
97
  if text:
98
+ file, start = _origin(buf_start)
99
+ _, end = _origin(end_line)
100
+ paragraphs.append(
101
+ Paragraph(text=text, start_line=start, end_line=end, file=file)
102
+ )
103
  buf.clear()
104
 
105
  for i, line in enumerate(lines):
tests/test_includes.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from qalmsw.document import Document
4
+ from qalmsw.parse import resolve_includes
5
+
6
+
7
+ def _write(path: Path, content: str) -> None:
8
+ path.parent.mkdir(parents=True, exist_ok=True)
9
+ path.write_text(content, encoding="utf-8")
10
+
11
+
12
+ def test_no_includes_is_passthrough(tmp_path: Path):
13
+ main = tmp_path / "main.tex"
14
+ body = "\\begin{document}\nHello world.\n\\end{document}\n"
15
+ _write(main, body)
16
+ source, line_map = resolve_includes(main)
17
+ assert source == body
18
+ assert len(line_map) == body.count("\n")
19
+ assert all(entry.file == main for entry in line_map)
20
+ assert [e.line for e in line_map] == list(range(1, len(line_map) + 1))
21
+
22
+
23
+ def test_input_inlined(tmp_path: Path):
24
+ main = tmp_path / "main.tex"
25
+ intro = tmp_path / "sections" / "intro.tex"
26
+ _write(intro, "Intro line one.\nIntro line two.\n")
27
+ _write(main, "\\begin{document}\n\\input{sections/intro}\n\\end{document}\n")
28
+ source, line_map = resolve_includes(main)
29
+ assert "Intro line one." in source
30
+ assert "Intro line two." in source
31
+ # The line containing "Intro line one." should map back to intro.tex:1.
32
+ lines = source.split("\n")
33
+ idx = lines.index("Intro line one.")
34
+ assert line_map[idx].file == intro
35
+ assert line_map[idx].line == 1
36
+ idx2 = lines.index("Intro line two.")
37
+ assert line_map[idx2].file == intro
38
+ assert line_map[idx2].line == 2
39
+
40
+
41
+ def test_include_adds_tex_suffix(tmp_path: Path):
42
+ main = tmp_path / "main.tex"
43
+ sub = tmp_path / "part.tex"
44
+ _write(sub, "Sub content.\n")
45
+ _write(main, "\\input{part}\n")
46
+ source, line_map = resolve_includes(main)
47
+ assert "Sub content." in source
48
+ lines = source.split("\n")
49
+ idx = lines.index("Sub content.")
50
+ assert line_map[idx].file == sub
51
+
52
+
53
+ def test_missing_include_is_tolerated(tmp_path: Path):
54
+ main = tmp_path / "main.tex"
55
+ _write(main, "Before\n\\input{does_not_exist}\nAfter\n")
56
+ source, line_map = resolve_includes(main)
57
+ assert "Before" in source
58
+ assert "After" in source
59
+ assert "qalmsw" in source # placeholder comment emitted
60
+
61
+
62
+ def test_cycle_detected(tmp_path: Path):
63
+ a = tmp_path / "a.tex"
64
+ b = tmp_path / "b.tex"
65
+ _write(a, "A\n\\input{b}\n")
66
+ _write(b, "B\n\\input{a}\n")
67
+ source, _ = resolve_includes(a)
68
+ # Should not infinite-loop; cycle back to a is refused.
69
+ assert source.count("cycle detected") >= 1
70
+
71
+
72
+ def test_nested_includes(tmp_path: Path):
73
+ main = tmp_path / "main.tex"
74
+ mid = tmp_path / "mid.tex"
75
+ leaf = tmp_path / "leaf.tex"
76
+ _write(leaf, "Leaf content.\n")
77
+ _write(mid, "Mid content.\n\\input{leaf}\n")
78
+ _write(main, "\\input{mid}\n")
79
+ source, line_map = resolve_includes(main)
80
+ assert "Mid content." in source
81
+ assert "Leaf content." in source
82
+ lines = source.split("\n")
83
+ assert line_map[lines.index("Leaf content.")].file == leaf
84
+ assert line_map[lines.index("Mid content.")].file == mid
85
+
86
+
87
+ def test_input_in_comment_not_followed(tmp_path: Path):
88
+ main = tmp_path / "main.tex"
89
+ _write(main, "Body\n% \\input{ignored}\n")
90
+ source, _ = resolve_includes(main)
91
+ # The commented line is kept verbatim (we preserve it as-is); we just don't follow it.
92
+ assert "ignored" in source # text preserved
93
+ # But no separate file was created, so no expansion occurred; line count matches input.
94
+ assert source.count("\n") == 2
95
+
96
+
97
+ def test_document_load_attributes_paragraphs_to_origin(tmp_path: Path):
98
+ main = tmp_path / "main.tex"
99
+ intro = tmp_path / "intro.tex"
100
+ _write(intro, "Paragraph from intro with enough words.\n")
101
+ _write(
102
+ main,
103
+ "\\begin{document}\n"
104
+ "\\input{intro}\n"
105
+ "\n"
106
+ "Paragraph in main.\n"
107
+ "\\end{document}\n",
108
+ )
109
+ doc = Document.load(main)
110
+ texts = [p.text for p in doc.paragraphs]
111
+ assert "Paragraph from intro with enough words." in texts
112
+ assert "Paragraph in main." in texts
113
+ from_intro = next(p for p in doc.paragraphs if "from intro" in p.text)
114
+ from_main = next(p for p in doc.paragraphs if "in main" in p.text)
115
+ assert from_intro.file == intro
116
+ assert from_intro.start_line == 1
117
+ assert from_main.file == main
tests/test_inline_bib.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from qalmsw.bib import extract_inline_bibitems
4
+ from qalmsw.parse.includes import LineMapEntry
5
+
6
+
7
+ def test_extracts_keys_from_thebibliography():
8
+ src = (
9
+ "\\begin{document}\n"
10
+ "Body.\n"
11
+ "\\begin{thebibliography}{99}\n"
12
+ "\\bibitem{foo} A. Author, ``A Title'', Venue, 2020.\n"
13
+ "\\bibitem{bar} B. Author, Something, 2021.\n"
14
+ "\\end{thebibliography}\n"
15
+ "\\end{document}\n"
16
+ )
17
+ entries = extract_inline_bibitems(src, default_file=Path("paper.tex"))
18
+ keys = [e.key for e in entries]
19
+ assert keys == ["foo", "bar"]
20
+ assert entries[0].entry_type == "bibitem"
21
+ assert entries[0].file == Path("paper.tex")
22
+ assert entries[0].line == 4
23
+
24
+
25
+ def test_title_guessed_from_quoted_text():
26
+ src = (
27
+ "\\begin{thebibliography}{99}\n"
28
+ "\\bibitem{foo} A. Author, ``Attention Is All You Need'', NeurIPS, 2017.\n"
29
+ "\\end{thebibliography}\n"
30
+ )
31
+ entries = extract_inline_bibitems(src)
32
+ assert entries[0].title == "Attention Is All You Need"
33
+
34
+
35
+ def test_title_guessed_from_textit():
36
+ src = (
37
+ "\\begin{thebibliography}{99}\n"
38
+ "\\bibitem{foo} A. Author, \\textit{A Book Title}, Publisher, 2020.\n"
39
+ "\\end{thebibliography}\n"
40
+ )
41
+ entries = extract_inline_bibitems(src)
42
+ assert entries[0].title == "A Book Title"
43
+
44
+
45
+ def test_missing_title_is_empty():
46
+ src = (
47
+ "\\begin{thebibliography}{99}\n"
48
+ "\\bibitem{foo} A. Author. No title markup. 2020.\n"
49
+ "\\end{thebibliography}\n"
50
+ )
51
+ entries = extract_inline_bibitems(src)
52
+ assert entries[0].title == ""
53
+
54
+
55
+ def test_no_thebibliography_yields_nothing():
56
+ src = "\\begin{document}\nNo bib here.\n\\end{document}\n"
57
+ assert extract_inline_bibitems(src) == []
58
+
59
+
60
+ def test_bibitem_with_optional_label():
61
+ src = (
62
+ "\\begin{thebibliography}{99}\n"
63
+ "\\bibitem[Smith2020]{smith2020} Smith, ``X''.\n"
64
+ "\\end{thebibliography}\n"
65
+ )
66
+ entries = extract_inline_bibitems(src)
67
+ assert entries[0].key == "smith2020"
68
+
69
+
70
+ def test_line_map_attributes_to_original_file():
71
+ src = (
72
+ "\\begin{thebibliography}{99}\n" # combined line 1
73
+ "\\bibitem{foo} text.\n" # combined line 2
74
+ "\\end{thebibliography}\n" # combined line 3
75
+ )
76
+ main = Path("main.tex")
77
+ refs = Path("refs.tex")
78
+ line_map = [
79
+ LineMapEntry(refs, 10),
80
+ LineMapEntry(refs, 11),
81
+ LineMapEntry(refs, 12),
82
+ ]
83
+ entries = extract_inline_bibitems(src, line_map=line_map, default_file=main)
84
+ assert entries[0].file == refs
85
+ assert entries[0].line == 11