Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

663 lines
20 KiB
Go

package languages
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// JupyterExtractor extracts cell-structured notebooks:
//
// - Jupyter `.ipynb` (nbformat 3 + 4) — a JSON document with a
// `cells[]` array (4) or `worksheets[].cells[]` array (3). Each
// cell becomes one graph node: code cells materialise as
// KindFunction (so agents can search them with kind:function),
// markdown / raw / heading cells materialise as KindVariable
// (matching markdown.go's heading-and-codeblock convention).
// Each cell carries Meta["cell_index"] (0-based), Meta["cell_kind"]
// ∈ code|markdown|raw|heading, and Meta["cell_language"]. A code
// cell's language defaults to the notebook's kernelspec, but a
// leading `%%sql` / `%%scala` / `%%r` / `%%bash` / `%%html` cell
// magic overrides it.
//
// - Databricks `.dbc` archives — ZIP files containing one or more
// notebooks. Each entry is parsed first as the Databricks-native
// JSON shape (`commands[]`) and, on shape mismatch, as nbformat.
// Cells emitted from archive members carry Meta["archive_member"].
//
// Databricks source-format notebooks (`.py` / `.scala` / `.sql` /
// `.R` files whose first significant line is a `Databricks notebook
// source` magic comment) share their file extension with regular
// source files, so the JupyterExtractor never owns them directly.
// Instead the host-language extractor (PythonExtractor, ScalaExtractor,
// SQLExtractor, RExtractor) calls MaybeEnrichDatabricks at the end
// of its Extract pass — cell-level nodes ride alongside the host
// extractor's regular symbol nodes without conflicting IDs.
type JupyterExtractor struct{}
// NewJupyterExtractor returns the notebook extractor for `.ipynb`
// and `.dbc` files.
func NewJupyterExtractor() *JupyterExtractor { return &JupyterExtractor{} }
func (e *JupyterExtractor) Language() string { return "jupyter" }
func (e *JupyterExtractor) Extensions() []string { return []string{".ipynb", ".dbc"} }
// dbcZipMaxBytes caps the uncompressed total we read from a `.dbc`
// archive, mirroring zip-bomb defenses elsewhere in the indexer.
const dbcZipMaxBytes = 50 * 1024 * 1024
// ipynbNotebook is the minimal nbformat shape we need.
type ipynbNotebook struct {
NBFormat int `json:"nbformat"`
Metadata map[string]any `json:"metadata"`
Cells []ipynbCell `json:"cells"`
Worksheets []ipynbV3Worksheet `json:"worksheets"`
}
type ipynbV3Worksheet struct {
Cells []ipynbCell `json:"cells"`
}
// ipynbCell is the minimal cell shape we need. The `source` field is
// either a string or a list of strings per nbformat — see stringOrList.
type ipynbCell struct {
CellType string `json:"cell_type"`
Source stringOrList `json:"source"`
Language string `json:"language"`
Metadata map[string]any `json:"metadata"`
}
// stringOrList accepts either a JSON string or an array of strings.
// nbformat spec allows either; both Jupyter and Databricks emit the
// array form by default but legacy notebooks use the string form.
type stringOrList string
func (s *stringOrList) UnmarshalJSON(b []byte) error {
if len(b) == 0 || bytes.Equal(b, []byte("null")) {
*s = ""
return nil
}
switch b[0] {
case '"':
var x string
if err := json.Unmarshal(b, &x); err != nil {
return err
}
*s = stringOrList(x)
return nil
case '[':
var list []string
if err := json.Unmarshal(b, &list); err != nil {
return err
}
*s = stringOrList(strings.Join(list, ""))
return nil
}
return fmt.Errorf("ipynb cell source: unexpected JSON shape: %s", string(b))
}
// Extract dispatches on the extension. Free-standing `.ipynb` →
// JSON walk; `.dbc` → ZIP archive walk. Malformed inputs yield only
// the file node so indexing continues for the rest of the repo.
func (e *JupyterExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
result := &parser.ExtractionResult{}
ext := strings.ToLower(filepath.Ext(filePath))
endLine := lineCount(src)
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, EndLine: endLine,
Language: jupyterFileLanguage(ext),
}
result.Nodes = append(result.Nodes, fileNode)
switch ext {
case ".ipynb":
if len(bytes.TrimSpace(src)) == 0 {
return result, nil
}
notebookLang, cells := parseIPYNB(src)
emitIPYNBCells(filePath, fileNode.ID, "", notebookLang, cells, result)
case ".dbc":
extractDBCArchive(filePath, fileNode.ID, src, result)
}
return result, nil
}
// jupyterFileLanguage returns the language label for the file node.
// Cell-level Meta["cell_language"] is what agents use for per-cell
// routing; the file-level language is the umbrella.
func jupyterFileLanguage(ext string) string {
if ext == ".dbc" {
return "databricks"
}
return "jupyter"
}
// parseIPYNB decodes a notebook (nbformat 3 or 4+). Malformed
// documents yield an empty result rather than an error so the rest
// of the repo still indexes.
func parseIPYNB(src []byte) (notebookLang string, cells []ipynbCell) {
var nb ipynbNotebook
if err := json.Unmarshal(src, &nb); err != nil {
return "", nil
}
notebookLang = ipynbKernelLanguage(nb.Metadata)
if len(nb.Cells) > 0 {
return notebookLang, nb.Cells
}
for _, ws := range nb.Worksheets {
cells = append(cells, ws.Cells...)
}
return notebookLang, cells
}
// ipynbKernelLanguage pulls the notebook's kernel language from the
// top-level metadata. Falls through several documented locations and
// finally to "python" — the dominant kernel and what nbformat
// notebooks that omit the metadata default to in practice.
func ipynbKernelLanguage(md map[string]any) string {
if md == nil {
return "python"
}
if ks, ok := md["kernelspec"].(map[string]any); ok {
if s, ok := ks["language"].(string); ok && s != "" {
return strings.ToLower(s)
}
if s, ok := ks["name"].(string); ok && s != "" {
return strings.ToLower(s)
}
}
if li, ok := md["language_info"].(map[string]any); ok {
if s, ok := li["name"].(string); ok && s != "" {
return strings.ToLower(s)
}
}
return "python"
}
// jupyterCellMagicRe captures the language token from a leading
// `%%lang ...` line in a code cell. Cell magics override the
// notebook's kernel language for that cell.
var jupyterCellMagicRe = regexp.MustCompile(`(?m)\A\s*%%(\w+)`)
// emitIPYNBCells materialises one graph node per cell. archivePath
// is the entry path inside a `.dbc` archive (empty for free-standing
// `.ipynb` files); it disambiguates per-cell IDs across archive
// entries that share an outer file.
func emitIPYNBCells(filePath, fileID, archivePath, notebookLang string, cells []ipynbCell, result *parser.ExtractionResult) {
for i, c := range cells {
body := string(c.Source)
cellKind := strings.ToLower(strings.TrimSpace(c.CellType))
if cellKind == "" {
cellKind = "code"
}
cellLang := notebookLang
if c.Language != "" {
cellLang = strings.ToLower(c.Language)
}
switch cellKind {
case "markdown":
cellLang = "markdown"
case "raw":
cellLang = "raw"
case "heading":
cellLang = "markdown"
case "code":
if m := jupyterCellMagicRe.FindStringSubmatch(body); m != nil {
cellLang = mapJupyterMagic(m[1], cellLang)
}
}
emitNotebookCell(filePath, fileID, archivePath, i, cellKind, cellLang, body, result)
}
}
// mapJupyterMagic normalises an IPython cell-magic name to a Gortex
// cell_language label. Execution magics (%%time, %%capture, etc.)
// keep the surrounding kernel language. Unrecognised magics pass
// through lowercased.
func mapJupyterMagic(magic, defaultLang string) string {
switch strings.ToLower(magic) {
case "sql":
return "sql"
case "bash", "sh":
return "bash"
case "html":
return "html"
case "javascript", "js":
return "javascript"
case "scala":
return "scala"
case "r":
return "r"
case "python", "py", "python2", "python3":
return "python"
case "writefile", "capture", "time", "timeit", "prun", "matplotlib", "system":
return defaultLang
}
return strings.ToLower(magic)
}
// emitNotebookCell appends a cell node + EdgeDefines from the file
// node. Code cells → KindFunction (so kind:function search finds
// them); markdown / raw / heading cells → KindVariable.
func emitNotebookCell(filePath, fileID, archivePath string, index int, cellKind, cellLang, body string, result *parser.ExtractionResult) {
name := fmt.Sprintf("cell_%d", index)
if cellKind != "" && cellKind != "code" {
name = fmt.Sprintf("%s_cell_%d", cellKind, index)
}
id := filePath + "::" + name
if archivePath != "" {
id = filePath + "::" + archivePath + "::" + name
}
startLine := 1
endLine := max(startLine, startLine+notebookBodyLines(body)-1)
kind := graph.KindVariable
if cellKind == "code" {
kind = graph.KindFunction
}
meta := map[string]any{
"cell_index": index,
"cell_kind": cellKind,
"cell_language": cellLang,
}
if archivePath != "" {
meta["archive_member"] = archivePath
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: kind, Name: name,
FilePath: filePath, StartLine: startLine, EndLine: endLine,
Language: cellLang, Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileID, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: startLine,
})
}
// notebookBodyLines counts the \n-delimited lines in a cell body
// with a floor of 1 so single-line and empty cells still get a
// well-defined endLine.
func notebookBodyLines(s string) int {
if s == "" {
return 1
}
n := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
n++
}
return max(n, 1)
}
// ---------------------------------------------------------------------------
// Databricks source-format notebooks
// ---------------------------------------------------------------------------
// databricksMagicHeaderFor builds a magic-header regex that matches
// only the comment style for one language family. Per language:
//
// python / r : `# Databricks notebook source`
// scala : `// Databricks notebook source`
// sql : `-- Databricks notebook source`
//
// Case-insensitive on the marker phrase, strict on the comment prefix
// — a Scala-style `// Databricks notebook source` in a `.py` file
// must NOT be classified as a Databricks notebook (it would crash
// the Python parser otherwise).
func databricksMagicHeaderFor(marker string) *regexp.Regexp {
return regexp.MustCompile(`(?i)^\s*` + regexp.QuoteMeta(marker) + `\s*Databricks notebook source`)
}
// databricksLangFromExt maps a Databricks source-format extension to
// its host language. Used as the default cell language for code
// cells that don't carry a `%magic` override.
func databricksLangFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".py":
return "python"
case ".scala":
return "scala"
case ".sql":
return "sql"
case ".r":
return "r"
}
return ""
}
// databricksCommentMarker returns the line-comment marker that
// introduces `COMMAND ----------` and `MAGIC %lang` lines for a
// given Databricks source-format extension.
func databricksCommentMarker(ext string) string {
switch strings.ToLower(ext) {
case ".py", ".r":
return "#"
case ".scala":
return "//"
case ".sql":
return "--"
}
return ""
}
// IsDatabricksSourceFile reports whether src is a Databricks
// source-format notebook (its first non-blank line is the Databricks
// magic header for the file's comment style). Cheap; reads only
// the first non-blank line.
func IsDatabricksSourceFile(filePath string, src []byte) bool {
ext := filepath.Ext(filePath)
if databricksLangFromExt(ext) == "" {
return false
}
marker := databricksCommentMarker(ext)
if marker == "" {
return false
}
trimmed := bytes.TrimLeft(src, " \t\r\n")
if i := bytes.IndexByte(trimmed, '\n'); i >= 0 {
trimmed = trimmed[:i]
}
return databricksMagicHeaderFor(marker).Match(trimmed)
}
// MaybeEnrichDatabricks adds Databricks cell-level nodes to result
// when filePath is a Databricks source-format notebook. Returns
// true when cells were emitted. The host-language extractor
// (PythonExtractor / ScalaExtractor / SQLExtractor / RExtractor)
// calls this at the end of its Extract pass — the cell nodes ride
// alongside the host extractor's regular symbol nodes.
//
// fileID is the host extractor's file-node ID (typically filePath).
// Cell IDs use a `dbx_cell_<i>` prefix so they can't collide with
// the host extractor's symbol IDs.
func MaybeEnrichDatabricks(filePath, fileID string, src []byte, result *parser.ExtractionResult) bool {
ext := filepath.Ext(filePath)
hostLang := databricksLangFromExt(ext)
if hostLang == "" {
return false
}
if !IsDatabricksSourceFile(filePath, src) {
return false
}
marker := databricksCommentMarker(ext)
cells := splitDatabricksCells(src, marker)
for i, cell := range cells {
cellLang, body := classifyDatabricksCell(cell.body, marker, hostLang)
cellKind := "code"
if cellLang == "markdown" {
cellKind = "markdown"
}
id := filePath + "::dbx_cell_" + fmt.Sprint(i)
endLine := max(cell.startLine, cell.startLine+notebookBodyLines(body)-1)
kind := graph.KindFunction
if cellKind != "code" {
kind = graph.KindVariable
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: kind, Name: fmt.Sprintf("dbx_cell_%d", i),
FilePath: filePath, StartLine: cell.startLine, EndLine: endLine,
Language: cellLang,
Meta: map[string]any{
"cell_index": i,
"cell_kind": cellKind,
"cell_language": cellLang,
"notebook": "databricks",
"host_language": hostLang,
},
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileID, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: cell.startLine,
})
}
return len(cells) > 0
}
// databricksCell is one logical cell carved out of a Databricks
// source-format notebook.
type databricksCell struct {
startLine int // 1-based
body string // verbatim cell contents (magic prefixes intact)
}
// splitDatabricksCells splits src on the language-appropriate
// `COMMAND ----------` separator. The first cell starts after the
// magic header line. Blank-only segments are dropped.
func splitDatabricksCells(src []byte, marker string) []databricksCell {
// The trailing `-+` allows any number of dashes — Databricks
// canonically emits 10 but older exports vary.
sep := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(marker) + `\s*COMMAND\s*-+\s*$`)
lines := strings.Split(string(src), "\n")
// Walk past blank lines and the first comment-prefixed line —
// the magic header — so cell 0 starts at the first real content.
startIdx := 0
skippedHeader := false
for startIdx < len(lines) {
line := strings.TrimSpace(lines[startIdx])
if line == "" {
startIdx++
continue
}
if !skippedHeader && strings.HasPrefix(line, marker) {
skippedHeader = true
startIdx++
continue
}
break
}
var sepIdx []int
for i := startIdx; i < len(lines); i++ {
if sep.MatchString(lines[i]) {
sepIdx = append(sepIdx, i)
}
}
var cells []databricksCell
cellStart := startIdx
addCell := func(from, to int) {
// Skip leading-blank lines so startLine points at content.
for from < to && strings.TrimSpace(lines[from]) == "" {
from++
}
if from >= to {
return
}
body := strings.TrimRight(strings.Join(lines[from:to], "\n"), "\n")
if strings.TrimSpace(body) == "" {
return
}
cells = append(cells, databricksCell{startLine: from + 1, body: body})
}
for _, idx := range sepIdx {
addCell(cellStart, idx)
cellStart = idx + 1
}
addCell(cellStart, len(lines))
return cells
}
// classifyDatabricksCell inspects a cell body for a leading
// `MAGIC %lang ...` block. When present, every `MAGIC` line is
// stripped of its prefix and the cell language switches to the
// declared one. When absent, the cell stays in the host language.
func classifyDatabricksCell(body, marker, hostLang string) (cellLang, cleanBody string) {
lines := strings.Split(body, "\n")
magicPrefix := marker + " MAGIC"
magicPrefixAlt := marker + "MAGIC"
isMagicLine := func(line string) bool {
s := strings.TrimLeft(line, " \t")
return strings.HasPrefix(s, magicPrefix) || strings.HasPrefix(s, magicPrefixAlt)
}
stripMagic := func(line string) string {
s := strings.TrimLeft(line, " \t")
if rest, ok := strings.CutPrefix(s, magicPrefix); ok {
s = rest
} else if rest, ok := strings.CutPrefix(s, magicPrefixAlt); ok {
s = rest
}
return strings.TrimLeft(s, " \t")
}
if len(lines) == 0 || !isMagicLine(lines[0]) {
return hostLang, body
}
first := stripMagic(lines[0])
lang := hostLang
if token, ok := strings.CutPrefix(first, "%"); ok {
if i := strings.IndexAny(token, " \t"); i >= 0 {
token = token[:i]
}
lang = strings.ToLower(strings.TrimSpace(token))
}
var out []string
for i, line := range lines {
if isMagicLine(line) {
stripped := stripMagic(line)
if i == 0 && strings.HasPrefix(stripped, "%") {
// Drop the bare `%lang` directive — it's metadata.
continue
}
out = append(out, stripped)
continue
}
out = append(out, line)
}
return databricksMagicLang(lang), strings.Join(out, "\n")
}
// databricksMagicLang normalises a Databricks `%magic` token to a
// Gortex cell_language label. Unrecognised tokens pass through
// lowercased so non-canonical magics still surface.
func databricksMagicLang(s string) string {
switch s {
case "py", "python":
return "python"
case "scala":
return "scala"
case "r":
return "r"
case "sql":
return "sql"
case "md", "markdown":
return "markdown"
case "sh", "bash":
return "bash"
case "fs":
return "fs"
case "run":
return "run"
}
return strings.ToLower(s)
}
// ---------------------------------------------------------------------------
// .dbc archive extraction
// ---------------------------------------------------------------------------
// extractDBCArchive opens src as a ZIP archive and emits cells for
// every notebook entry inside. Supports `.ipynb` (nbformat) and
// Databricks-native JSON (`commands[]`). Non-notebook entries are
// ignored. Total uncompressed bytes are capped at dbcZipMaxBytes.
func extractDBCArchive(filePath, fileID string, src []byte, result *parser.ExtractionResult) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return
}
var consumed int64
for _, f := range zr.File {
if consumed >= dbcZipMaxBytes {
return
}
if f.FileInfo().IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(f.Name))
if ext != ".ipynb" && ext != ".json" {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
body, err := io.ReadAll(io.LimitReader(rc, dbcZipMaxBytes-consumed))
_ = rc.Close()
if err != nil {
continue
}
consumed += int64(len(body))
if cells, lang, ok := parseDBCNotebookJSON(body); ok {
emitIPYNBCells(filePath, fileID, f.Name, lang, cells, result)
continue
}
notebookLang, ipycells := parseIPYNB(body)
if len(ipycells) == 0 {
continue
}
emitIPYNBCells(filePath, fileID, f.Name, notebookLang, ipycells, result)
}
}
// dbcNotebook is the minimal Databricks-native notebook shape we
// extract. The format is undocumented but observable in any
// Databricks export: a top-level object with `language` and a
// `commands[]` array.
type dbcNotebook struct {
Language string `json:"language"`
Commands []dbcCommand `json:"commands"`
}
// dbcCommand is one cell in the Databricks-native format.
type dbcCommand struct {
Command string `json:"command"`
Language string `json:"language"`
SubType string `json:"subtype"`
}
// parseDBCNotebookJSON decodes a Databricks-native notebook JSON.
// Returns ok=false when the shape doesn't match so the caller can
// fall back to nbformat.
func parseDBCNotebookJSON(src []byte) (cells []ipynbCell, notebookLang string, ok bool) {
var nb dbcNotebook
if err := json.Unmarshal(src, &nb); err != nil {
return nil, "", false
}
if len(nb.Commands) == 0 {
return nil, "", false
}
notebookLang = strings.ToLower(nb.Language)
cells = make([]ipynbCell, 0, len(nb.Commands))
for _, c := range nb.Commands {
cellLang := strings.ToLower(c.Language)
if cellLang == "" {
cellLang = notebookLang
}
kind := "code"
if strings.EqualFold(c.SubType, "markdownCommand") {
kind = "markdown"
cellLang = "markdown"
}
cells = append(cells, ipynbCell{
CellType: kind,
Source: stringOrList(c.Command),
Language: cellLang,
})
}
return cells, notebookLang, true
}
var _ parser.Extractor = (*JupyterExtractor)(nil)