a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
424 lines
12 KiB
Go
424 lines
12 KiB
Go
package languages
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
// Quarto (.qmd) documents weave YAML frontmatter, prose, and executable
|
|
// code chunks into one literate file. This extractor is deliberately
|
|
// dependency-free and line-based — robust to CRLF and to ``` / ~~~
|
|
// fences of length >= 3 — and emits three families of nodes:
|
|
//
|
|
// - KindConfigKey, one per top-level frontmatter key, so a doc's
|
|
// declared metadata (title, format, params, …) is searchable.
|
|
// - KindDoc, one per heading-delimited prose section, mirroring the
|
|
// Markdown prose convention (breadcrumb name, stripped section body,
|
|
// heading-derived stable ID).
|
|
// - KindFunction, one per executable code chunk (```{r}, ```{python},
|
|
// …), named by its chunk label when present. Plain fenced blocks
|
|
// without a {lang} brace info string are ordinary documentation code
|
|
// and are skipped.
|
|
//
|
|
// Each emitted node is linked from the file node by an EdgeDefines edge.
|
|
|
|
// QuartoExtractor extracts Quarto literate-programming documents.
|
|
type QuartoExtractor struct{}
|
|
|
|
// NewQuartoExtractor constructs a QuartoExtractor.
|
|
func NewQuartoExtractor() *QuartoExtractor { return &QuartoExtractor{} }
|
|
|
|
func (e *QuartoExtractor) Language() string { return "quarto" }
|
|
func (e *QuartoExtractor) Extensions() []string { return []string{".qmd"} }
|
|
|
|
// quartoSection accumulates one heading-delimited prose region while the
|
|
// scanner walks the document in source order.
|
|
type quartoSection struct {
|
|
level int // heading depth (1..6)
|
|
crumbs []string // heading-path breadcrumb, root-first
|
|
startLine int // 1-based line of the opening heading
|
|
endLine int // 1-based line of the last content line seen
|
|
body strings.Builder
|
|
}
|
|
|
|
func (e *QuartoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
// Normalise line endings so CRLF files scan identically to LF ones.
|
|
text := strings.ReplaceAll(string(src), "\r\n", "\n")
|
|
text = strings.ReplaceAll(text, "\r", "\n")
|
|
lines := strings.Split(text, "\n")
|
|
|
|
result := &parser.ExtractionResult{}
|
|
fileID := filePath
|
|
fileNode := &graph.Node{
|
|
ID: fileID, Kind: graph.KindFile, Name: filePath,
|
|
FilePath: filePath, StartLine: 1, EndLine: len(lines),
|
|
Language: "quarto",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
emitDefine := func(node *graph.Node, line int) {
|
|
result.Nodes = append(result.Nodes, node)
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileID, To: node.ID, Kind: graph.EdgeDefines,
|
|
FilePath: filePath, Line: line,
|
|
})
|
|
}
|
|
|
|
// --- 1. YAML frontmatter: top-level keys --------------------------
|
|
// Frontmatter is the leading `---` fence and its closing `---`/`...`.
|
|
bodyStart := 0
|
|
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
|
for i := 1; i < len(lines); i++ {
|
|
tr := strings.TrimSpace(lines[i])
|
|
if tr == "---" || tr == "..." {
|
|
bodyStart = i + 1
|
|
break
|
|
}
|
|
// Only top-level keys: a `key:` line with no leading
|
|
// indentation (so nested mapping/sequence members are skipped).
|
|
if key := topLevelYAMLKey(lines[i]); key != "" {
|
|
id := filePath + "::frontmatter::" + key
|
|
emitDefine(&graph.Node{
|
|
ID: id, Kind: graph.KindConfigKey, Name: key,
|
|
FilePath: filePath, StartLine: i + 1, EndLine: i + 1,
|
|
Language: "quarto",
|
|
Meta: map[string]any{"source": "quarto_frontmatter"},
|
|
}, i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 2 & 3. Prose sections and executable code chunks -------------
|
|
// A single fence-aware pass: track whether we're inside a fenced
|
|
// block so `#` lines inside code aren't mistaken for headings, and
|
|
// so chunk bodies aren't scanned for prose.
|
|
var (
|
|
sections []*quartoSection
|
|
stack []*quartoSection
|
|
seenSecID = make(map[string]bool)
|
|
chunkCount = make(map[string]int) // per-language chunk counter
|
|
)
|
|
|
|
closeTo := func(level int) {
|
|
for len(stack) > 0 && stack[len(stack)-1].level >= level {
|
|
sections = append(sections, stack[len(stack)-1])
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
}
|
|
appendProse := func(line int, raw string) {
|
|
txt := strings.TrimSpace(stripMarkdownInline(raw))
|
|
if txt == "" || len(stack) == 0 {
|
|
return
|
|
}
|
|
top := stack[len(stack)-1]
|
|
if top.body.Len() > 0 {
|
|
top.body.WriteByte(' ')
|
|
}
|
|
top.body.WriteString(txt)
|
|
if line > top.endLine {
|
|
top.endLine = line
|
|
}
|
|
}
|
|
|
|
i := bodyStart
|
|
for i < len(lines) {
|
|
line := lines[i]
|
|
if fence, ok := fenceOpener(line); ok {
|
|
// A fenced block. Collect its body up to the matching closer.
|
|
info := fenceInfo(line, fence)
|
|
chunkStart := i + 1 // 1-based line of the opening fence
|
|
j := i + 1
|
|
var body []string
|
|
for j < len(lines) {
|
|
if fenceCloser(lines[j], fence) {
|
|
break
|
|
}
|
|
body = append(body, lines[j])
|
|
j++
|
|
}
|
|
chunkEnd := j + 1 // 1-based line of the closing fence (or EOF)
|
|
if j >= len(lines) {
|
|
chunkEnd = len(lines)
|
|
}
|
|
|
|
if lang, ok := chunkLanguage(info); ok {
|
|
// Executable code chunk → KindFunction.
|
|
label := chunkLabel(info, body)
|
|
name := label
|
|
if name == "" {
|
|
chunkCount[lang]++
|
|
name = fmt.Sprintf("chunk-%s-%d", lang, chunkCount[lang])
|
|
}
|
|
id := filePath + "::chunk:" + name
|
|
emitDefine(&graph.Node{
|
|
ID: id, Kind: graph.KindFunction, Name: name,
|
|
FilePath: filePath, StartLine: chunkStart, EndLine: chunkEnd,
|
|
Language: "quarto",
|
|
Meta: map[string]any{"chunk_language": lang},
|
|
}, chunkStart)
|
|
}
|
|
// Plain fenced block (no {lang} brace) → ordinary doc code, skip.
|
|
|
|
// Advance past the closing fence.
|
|
i = j + 1
|
|
continue
|
|
}
|
|
|
|
// Outside any fence: a `#`-prefixed line is a heading.
|
|
if level, headingText, ok := atxHeading(line); ok {
|
|
closeTo(level)
|
|
var crumbs []string
|
|
if len(stack) > 0 {
|
|
crumbs = append(crumbs, stack[len(stack)-1].crumbs...)
|
|
}
|
|
crumbs = append(crumbs, headingText)
|
|
stack = append(stack, &quartoSection{
|
|
level: level, crumbs: crumbs, startLine: i + 1, endLine: i + 1,
|
|
})
|
|
i++
|
|
continue
|
|
}
|
|
|
|
appendProse(i+1, line)
|
|
i++
|
|
}
|
|
closeTo(1)
|
|
sections = append(sections, stack...)
|
|
|
|
base := proseFileBase(filePath)
|
|
for _, sec := range sections {
|
|
if strings.TrimSpace(sec.body.String()) == "" {
|
|
continue // no prose body → no search signal
|
|
}
|
|
id := proseSectionID(filePath, base, sec.crumbs)
|
|
if seenSecID[id] {
|
|
id = proseSectionID(filePath, base, append(sec.crumbs, "")) + idSlug(itoaSmall(len(seenSecID)))
|
|
}
|
|
seenSecID[id] = true
|
|
emitDefine(&graph.Node{
|
|
ID: id, Kind: graph.KindDoc, Name: proseBreadcrumb(base, sec.crumbs),
|
|
FilePath: filePath, StartLine: sec.startLine, EndLine: sec.endLine,
|
|
Language: "quarto",
|
|
Meta: map[string]any{
|
|
"section_text": sec.body.String(),
|
|
"heading_path": sec.crumbs,
|
|
"heading_level": sec.level,
|
|
},
|
|
}, sec.startLine)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// topLevelYAMLKey returns the key name of a top-level `key:` frontmatter
|
|
// line, or "" when the line is indented (a nested member), blank, a
|
|
// comment, a sequence item, or carries no `:` separator.
|
|
func topLevelYAMLKey(line string) string {
|
|
// Top-level keys have no leading indentation.
|
|
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
|
return ""
|
|
}
|
|
tr := strings.TrimRight(line, " \t")
|
|
if tr == "" || strings.HasPrefix(tr, "#") || strings.HasPrefix(tr, "-") {
|
|
return ""
|
|
}
|
|
idx := strings.IndexByte(tr, ':')
|
|
if idx <= 0 {
|
|
return ""
|
|
}
|
|
key := strings.TrimSpace(tr[:idx])
|
|
if key == "" || strings.ContainsAny(key, " \t") {
|
|
// A bare scalar / multi-word line is not a mapping key.
|
|
return ""
|
|
}
|
|
return key
|
|
}
|
|
|
|
// atxHeading reports whether line is an ATX heading (1..6 leading `#`
|
|
// followed by whitespace) and returns its level and trimmed text.
|
|
func atxHeading(line string) (level int, text string, ok bool) {
|
|
s := strings.TrimLeft(line, " ")
|
|
n := 0
|
|
for n < len(s) && s[n] == '#' {
|
|
n++
|
|
}
|
|
if n == 0 || n > 6 {
|
|
return 0, "", false
|
|
}
|
|
if n >= len(s) || (s[n] != ' ' && s[n] != '\t') {
|
|
return 0, "", false // `#foo` is not a heading
|
|
}
|
|
text = strings.TrimSpace(strings.TrimRight(s[n:], "#"))
|
|
if text == "" {
|
|
return 0, "", false
|
|
}
|
|
return n, text, true
|
|
}
|
|
|
|
// fenceOpener reports whether line opens a fenced block and returns the
|
|
// fence run (the leading ``` / ~~~ of length >= 3) used to match its
|
|
// closer.
|
|
func fenceOpener(line string) (fence string, ok bool) {
|
|
return fenceRun(line)
|
|
}
|
|
|
|
// fenceCloser reports whether line closes a block opened with the given
|
|
// fence: a line consisting only of a fence run of the same character,
|
|
// at least as long as the opener, with no trailing info string.
|
|
func fenceCloser(line, opener string) bool {
|
|
run, ok := fenceRun(line)
|
|
if !ok || len(run) == 0 || run[0] != opener[0] || len(run) < len(opener) {
|
|
return false
|
|
}
|
|
// A closer carries no info string.
|
|
rest := strings.TrimLeft(line, " ")
|
|
return strings.TrimSpace(rest[len(run):]) == ""
|
|
}
|
|
|
|
// fenceRun returns the leading fence run (3+ backticks or tildes,
|
|
// allowing up to 3 spaces of indentation) of a line.
|
|
func fenceRun(line string) (string, bool) {
|
|
s := line
|
|
// Up to three leading spaces are permitted before a fence.
|
|
indent := 0
|
|
for indent < len(s) && indent < 4 && s[indent] == ' ' {
|
|
indent++
|
|
}
|
|
if indent >= 4 {
|
|
return "", false
|
|
}
|
|
s = s[indent:]
|
|
if len(s) < 3 {
|
|
return "", false
|
|
}
|
|
ch := s[0]
|
|
if ch != '`' && ch != '~' {
|
|
return "", false
|
|
}
|
|
n := 0
|
|
for n < len(s) && s[n] == ch {
|
|
n++
|
|
}
|
|
if n < 3 {
|
|
return "", false
|
|
}
|
|
return s[:n], true
|
|
}
|
|
|
|
// fenceInfo returns the info string of a fence opener — everything after
|
|
// the fence run, trimmed.
|
|
func fenceInfo(line, fence string) string {
|
|
s := strings.TrimLeft(line, " ")
|
|
if len(s) < len(fence) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(s[len(fence):])
|
|
}
|
|
|
|
// chunkLanguage extracts the executable language from a Quarto brace
|
|
// info string like `{r}`, `{python}`, or `{r, label="fig1"}`. Plain
|
|
// info strings without a leading `{lang}` brace (e.g. `r` or `python`)
|
|
// are NOT executable chunks and yield ok=false.
|
|
func chunkLanguage(info string) (lang string, ok bool) {
|
|
if !strings.HasPrefix(info, "{") {
|
|
return "", false
|
|
}
|
|
end := strings.IndexByte(info, '}')
|
|
if end < 0 {
|
|
return "", false
|
|
}
|
|
inner := info[1:end]
|
|
// The language is the first token, before any comma or whitespace.
|
|
if c := strings.IndexByte(inner, ','); c >= 0 {
|
|
inner = inner[:c]
|
|
}
|
|
lang = strings.TrimSpace(inner)
|
|
// A leading `=` (e.g. `{=html}`) marks a raw passthrough block.
|
|
lang = strings.TrimPrefix(lang, "=")
|
|
if lang == "" {
|
|
return "", false
|
|
}
|
|
return lang, true
|
|
}
|
|
|
|
// chunkLabel returns the chunk label from either the brace options
|
|
// (`{r, label="fig1"}`) or a leading `#| label: fig1` option line in the
|
|
// chunk body. The empty string means the chunk is unlabelled.
|
|
func chunkLabel(info string, body []string) string {
|
|
// Brace form: {r, label="fig1"} or {r, label='fig1'} or {r label=fig1}.
|
|
if lbl := braceLabel(info); lbl != "" {
|
|
return lbl
|
|
}
|
|
// `#| label: fig1` option lines at the top of the chunk body.
|
|
for _, ln := range body {
|
|
t := strings.TrimSpace(ln)
|
|
if !strings.HasPrefix(t, "#|") {
|
|
// Once a non-option line appears, stop scanning — option
|
|
// lines only lead the chunk body.
|
|
if t == "" {
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
rest := strings.TrimSpace(strings.TrimPrefix(t, "#|"))
|
|
if v, ok := optionValue(rest, "label"); ok {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// braceLabel extracts a `label=...` option from a brace info string.
|
|
func braceLabel(info string) string {
|
|
end := strings.IndexByte(info, '}')
|
|
if !strings.HasPrefix(info, "{") || end < 0 {
|
|
return ""
|
|
}
|
|
inner := info[1:end]
|
|
idx := strings.Index(inner, "label")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
rest := strings.TrimSpace(inner[idx+len("label"):])
|
|
if !strings.HasPrefix(rest, "=") {
|
|
return ""
|
|
}
|
|
return unquote(strings.TrimSpace(strings.TrimPrefix(rest, "=")))
|
|
}
|
|
|
|
// optionValue parses a `key: value` YAML option line (the `#| key: val`
|
|
// form) and returns the unquoted value when key matches.
|
|
func optionValue(opt, key string) (string, bool) {
|
|
idx := strings.IndexByte(opt, ':')
|
|
if idx < 0 {
|
|
return "", false
|
|
}
|
|
if strings.TrimSpace(opt[:idx]) != key {
|
|
return "", false
|
|
}
|
|
return unquote(strings.TrimSpace(opt[idx+1:])), true
|
|
}
|
|
|
|
// unquote strips a single trailing/leading pair of matching quotes and
|
|
// any trailing comma, returning the bare token value.
|
|
func unquote(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimRight(s, ",")
|
|
s = strings.TrimSpace(s)
|
|
if len(s) >= 2 {
|
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
|
return s[1 : len(s)-1]
|
|
}
|
|
}
|
|
// A bare token may carry a trailing option separator.
|
|
if c := strings.IndexAny(s, " \t"); c >= 0 {
|
|
s = s[:c]
|
|
}
|
|
return s
|
|
}
|
|
|
|
var _ parser.Extractor = (*QuartoExtractor)(nil)
|