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

319 lines
9.8 KiB
Go

package languages
import (
"regexp"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// JCL (MVS/z-OS Job Control Language) extraction is line-based: every
// statement starts in column 1 with `//`. We model the job stream as a
// small call graph:
//
// //JOBNAME JOB ... -> KindFunction (the job)
// //STEPNAME EXEC PGM=PROGRAM -> KindMethod (a job step) + EdgeCalls
// //STEPNAME EXEC procname -> KindMethod (cataloged-proc step) + EdgeCalls
// //DDNAME DD DSN=dataset -> KindVariable (a DD) + EdgeReferences
//
// Continuation lines (operand field ending in a comma, next line `//` plus
// spaces) are folded into the owning statement on a best-effort basis so a
// PGM= or DSN= that spills to the next line is still captured.
var (
// A JCL name-bearing statement: `//NAME OP rest`. NAME is the label,
// OP is the operation (JOB / EXEC / DD / etc).
jclStmtRe = regexp.MustCompile(`^//([A-Za-z$#@][A-Za-z0-9$#@]*)\s+(\S+)\s*(.*)$`)
// PGM=program operand on an EXEC statement.
jclPgmRe = regexp.MustCompile(`(?i)\bPGM=([A-Za-z$#@][A-Za-z0-9$#@.]*)`)
// PROC=procname operand (explicit form of EXEC procname).
jclProcRe = regexp.MustCompile(`(?i)\bPROC=([A-Za-z$#@][A-Za-z0-9$#@.]*)`)
// DSN= / DSNAME= dataset operand on a DD statement.
jclDSNRe = regexp.MustCompile(`(?i)\bDSN(?:AME)?=([A-Za-z0-9$#@.()'+\-]+)`)
)
// JCLExtractor extracts MVS/z-OS Job Control Language job streams.
type JCLExtractor struct{}
func NewJCLExtractor() *JCLExtractor { return &JCLExtractor{} }
func (e *JCLExtractor) Language() string { return "jcl" }
func (e *JCLExtractor) Extensions() []string { return []string{".jcl", ".job"} }
func (e *JCLExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
rawLines := strings.Split(string(src), "\n")
result := &parser.ExtractionResult{}
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, EndLine: len(rawLines),
Language: "jcl",
}
result.Nodes = append(result.Nodes, fileNode)
stmts := foldJCLStatements(rawLines)
seen := make(map[string]bool)
addNode := func(id string, kind graph.NodeKind, name string, line int, meta map[string]any) {
if seen[id] {
return
}
seen[id] = true
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: kind, Name: name,
FilePath: filePath, StartLine: line, EndLine: line,
Language: "jcl", Meta: meta,
})
}
addEdge := func(from, to string, kind graph.EdgeKind, line int) {
result.Edges = append(result.Edges, &graph.Edge{
From: from, To: to, Kind: kind, FilePath: filePath, Line: line,
})
}
jobID := "" // the most recent JOB node ID (parent for steps)
curStepID := "" // the most recent EXEC step node ID (parent for DDs)
curStepName := "" // step name, used to namespace DD node IDs
for _, st := range stmts {
m := jclStmtRe.FindStringSubmatch(st.text)
if m == nil {
continue
}
name, op, rest := m[1], strings.ToUpper(m[2]), m[3]
switch op {
case "JOB":
id := filePath + "::" + name
addNode(id, graph.KindFunction, name, st.line,
map[string]any{"jcl_kind": "job"})
addEdge(fileNode.ID, id, graph.EdgeDefines, st.line)
jobID = id
curStepID = ""
curStepName = ""
case "EXEC":
id := filePath + "::" + name
meta := map[string]any{"jcl_kind": "step"}
parent := fileNode.ID
if jobID != "" {
parent = jobID
}
if pm := jclPgmRe.FindStringSubmatch(rest); pm != nil {
pgm := pm[1]
meta["pgm"] = pgm
addNode(id, graph.KindMethod, name, st.line, meta)
addEdge(parent, id, graph.EdgeDefines, st.line)
addEdge(id, "unresolved::program::"+pgm, graph.EdgeCalls, st.line)
} else {
// EXEC procname (cataloged procedure) — either bare
// (first operand token) or the explicit PROC= form.
proc := ""
if pm := jclProcRe.FindStringSubmatch(rest); pm != nil {
proc = pm[1]
} else {
proc = firstOperandToken(rest)
}
if proc != "" {
meta["proc"] = proc
}
addNode(id, graph.KindMethod, name, st.line, meta)
addEdge(parent, id, graph.EdgeDefines, st.line)
if proc != "" {
addEdge(id, "unresolved::proc::"+proc, graph.EdgeCalls, st.line)
}
}
curStepID = id
curStepName = name
case "DD":
// DD node id is namespaced by the enclosing step when known,
// so duplicate DD names across steps don't collide.
ddID := filePath + "::DD:" + name
if curStepName != "" {
ddID = filePath + "::" + curStepName + "." + name
}
meta := map[string]any{"jcl_kind": "dd"}
dsn := ""
if dm := jclDSNRe.FindStringSubmatch(rest); dm != nil {
dsn = normalizeDSN(dm[1])
}
if dsn != "" {
meta["dsn"] = dsn
}
addNode(ddID, graph.KindVariable, name, st.line, meta)
parent := fileNode.ID
if curStepID != "" {
parent = curStepID
}
addEdge(parent, ddID, graph.EdgeDefines, st.line)
// DUMMY / SYSOUT-only DDs carry no dataset — emit no
// dataset reference for them.
if dsn != "" {
addEdge(parent, "unresolved::dataset::"+dsn, graph.EdgeReferences, st.line)
}
}
}
return result, nil
}
// jclStatement is a logical JCL statement after continuation folding.
type jclStatement struct {
text string // folded statement text (leading `//` + operands)
line int // 1-based line of the statement's first physical line
}
// foldJCLStatements collapses continuation lines into single logical
// statements. A statement continues when its operand field ends in a
// comma and the following line is `//` followed by whitespace (the
// continuation marker). Comment lines (`//*`) and the in-stream data
// delimiter (`/*`, `//`) are dropped. Best-effort: it folds the operand
// of the next continuation line onto the current statement so a PGM= /
// DSN= spilling to the next line is captured.
func foldJCLStatements(lines []string) []jclStatement {
var out []jclStatement
for i := 0; i < len(lines); i++ {
line := strings.TrimRight(lines[i], "\r")
// Comment line: `//*...`
if strings.HasPrefix(line, "//*") {
continue
}
// In-stream data delimiter `/*` or null statement `//`.
if line == "//" || strings.HasPrefix(line, "/*") {
continue
}
if !strings.HasPrefix(line, "//") {
continue // in-stream data or sequence noise
}
// A continuation marker (`//` + spaces) only makes sense after a
// statement; a leading one with no owner is skipped.
afterSlashes := line[2:]
if strings.TrimSpace(afterSlashes) == "" {
continue
}
if afterSlashes[0] == ' ' || afterSlashes[0] == '\t' {
// Orphan continuation (no preceding statement) — ignore.
continue
}
startLine := i + 1
// Drop the inline comment that JCL allows after a blank past the
// operand field. We keep it simple: strip everything after the
// first run of spaces that follows a space-terminated operand.
text := stripJCLComment(line)
// Fold continuation lines.
for endsWithContinuation(text) && i+1 < len(lines) {
next := strings.TrimRight(lines[i+1], "\r")
if !strings.HasPrefix(next, "//") {
break
}
cont := next[2:]
if cont == "" || (cont[0] != ' ' && cont[0] != '\t') {
break // next line is a new statement, not a continuation
}
operand := strings.TrimSpace(cont)
// Comment-only continuation.
text = strings.TrimRight(text, " ") + stripJCLComment(operand)
i++
}
out = append(out, jclStatement{text: text, line: startLine})
}
return out
}
// endsWithContinuation reports whether a JCL operand field continues on
// the next line — true when the (comment-stripped) text ends in a comma.
func endsWithContinuation(text string) bool {
t := strings.TrimRight(text, " \t")
return strings.HasSuffix(t, ",")
}
// stripJCLComment removes a trailing free-form comment from a JCL line.
// JCL comments begin after the operand field at the first space that is
// not inside quotes or parentheses. This is best-effort and conservative:
// it only strips when a space is found outside quotes/parens past the
// operation field.
func stripJCLComment(line string) string {
inQuote := false
depth := 0
// Locate the start of the operand field (the 3rd whitespace-delimited
// token: after the label and the operation). The comment, if any,
// begins at the first unquoted/unparenthesised space *within* the
// operand field, so we must not start scanning before it.
tokens := 0
prevSpace := true
operandStart := -1
for i := 0; i < len(line); i++ {
c := line[i]
if c == ' ' || c == '\t' {
prevSpace = true
} else {
if prevSpace {
tokens++
if tokens == 3 {
operandStart = i
break
}
}
prevSpace = false
}
}
if operandStart < 0 {
return line // fewer than three tokens — no operand field to scan
}
for i := operandStart; i < len(line); i++ {
c := line[i]
switch c {
case '\'':
inQuote = !inQuote
case '(':
if !inQuote {
depth++
}
case ')':
if !inQuote {
depth--
}
case ' ', '\t':
if !inQuote && depth == 0 {
return line[:i]
}
}
}
return line
}
// firstOperandToken returns the first comma-or-space-delimited operand
// token of a JCL operand field (used for `EXEC procname`).
func firstOperandToken(rest string) string {
rest = strings.TrimSpace(rest)
if rest == "" {
return ""
}
end := len(rest)
for i, c := range rest {
if c == ',' || c == ' ' || c == '\t' || c == '(' {
end = i
break
}
}
tok := rest[:end]
// A bare token containing `=` is a keyword operand, not a proc name.
if strings.Contains(tok, "=") {
return ""
}
return tok
}
// normalizeDSN trims a DSN operand to the bare dataset name: it strips
// surrounding quotes and any member/GDG suffix in parentheses.
func normalizeDSN(dsn string) string {
dsn = strings.Trim(dsn, "'")
if idx := strings.IndexByte(dsn, '('); idx >= 0 {
dsn = dsn[:idx]
}
return strings.TrimRight(dsn, ",")
}
var _ parser.Extractor = (*JCLExtractor)(nil)