Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/languages.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Supported Languages

Gortex currently indexes **256 languages**. Each language has an extractor that
Gortex currently indexes **258 languages**. Each language has an extractor that
walks the source, emits symbols (functions, methods, types, interfaces,
variables) into the graph, and records `imports` / `calls` edges.

Expand Down Expand Up @@ -42,7 +42,7 @@ server matrix, install commands, lifecycle knobs, and config schema.
| Scripting & shell | 10 | Bash, PowerShell, Batch, Perl, Raku, Lua, Tcl, VimScript, AutoHotkey, CoffeeScript |
| Functional | 8 | Haskell, OCaml, Elixir, Clojure, Erlang, Racket, Gleam, Emacs Lisp |
| Systems / emerging | 8 | Nim, Crystal, Mojo, Odin, V, Hare, Carbon, ReScript |
| Scientific & enterprise | 12 | Julia, R, MATLAB, Mathematica, SAS, Stata, Fortran, COBOL, Ada, Pascal, ABAP, Apex |
| Scientific & enterprise | 14 | Julia, R, MATLAB, Mathematica, SAS, Stata, Fortran, COBOL, Ada, Pascal, ABAP, Apex, Qik (ABA), Qik XML |
| Mobile & game | 4 | Dart, GDScript, Verse, ActionScript |
| Blockchain / smart contracts | 6 | Solidity, Move, Cairo, Noir, Tact, Ballerina |
| Template engines | 8 | Blade, EJS, Handlebars, Jinja, Twig, ERB, Liquid, Pug |
Expand All @@ -57,7 +57,7 @@ server matrix, install commands, lifecycle knobs, and config schema.
| Forest — DB / query | 8 | SPARQL, SurrealQL, PromQL, Kusto, SOQL, SOSL, PRQL, Turtle |
| Forest — data / lockfiles / shells / configs | ~28 | TSV, PSV, textproto, .po, PGN, todo.txt, go.mod / go.sum / go.work, Fish, Nushell, jq, Awk, Elvish, gitconfig / gitattributes / gitcommit / gitignore, Hyprlang, nftables, passwd, PEM, PoE filter, Puppet, ssh_config, sxhkdrc, tmux |
| Forest — misc | ~14 | DOT, gnuplot, GPG, Strace, VRL, Zeek, Ziggy + Schema, Starlark, SourcePawn, SCSS, RBS, OCamllex, DataWeave, USD, WIT |
| **Total** | **256** | |
| **Total** | **258** | |

## Core programming — deep extraction

Expand Down Expand Up @@ -280,6 +280,8 @@ What is **not** covered:
| Ada | `.ada`, `.adb`, `.ads` | Packages, procedures, functions, `with` |
| Pascal / Delphi | `.pas`, `.pp`, `.dpr` | Units, procedures, functions, classes |
| ABAP (SAP) | `.abap` | `FORM` / `FUNCTION` / `METHOD` / `CLASS…DEFINITION`, `INCLUDE` |
| Qik (ABA/Sabre) | `.qik` | script unit, `label` / `goto`, `call` (other scripts), `build_local_data_item` |
| Qik XML (ABA) | `.xml` (content-sniffed) | `LocalDescRef` DATAITEM / TABLE descriptors, `ColumnDesc` columns |
| Apex (Salesforce) | `.cls`, `.trigger`, `.apex` | Classes, triggers, methods |

## Emerging languages
Expand Down
1 change: 1 addition & 0 deletions internal/indexer/extractor_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ var extractorSaltExtLang = map[string]string{
".exs": "elixir",
".sh": "bash",
".bash": "bash",
".qik": "qikbasic",
".jl": "julia",
}

Expand Down
23 changes: 21 additions & 2 deletions internal/parser/detect_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,18 @@ func sniffAmbiguous(filePath, ext string, content []byte) (string, bool) {
return "mathematica", true
}
case ".xml":
// A MyBatis mapper / Spring beans XML routes to its specific
// extractor; every other .xml keeps the generic "xml" default.
// A MyBatis mapper / Spring beans / ABA QIK descriptor XML routes
// to its specific extractor; every other .xml keeps the generic
// "xml" default.
if hasMyBatisMapperMarkers(probe) {
return "mybatis", true
}
if hasSpringBeansMarkers(probe) {
return "spring", true
}
if hasQikXMLMarkers(probe) {
return "qikxml", true
}
}
return "", false
}
Expand All @@ -204,6 +208,21 @@ func hasShopifyTemplateMarkers(b []byte) bool {
return bytes.Contains(b, []byte(`"sections"`)) && bytes.Contains(b, []byte(`"type"`))
}


// hasQikXMLMarkers reports whether the content is an ABA/Sabre QIK
// LocalDescRef descriptor (DATAITEM or TABLE AppObjectDesc). Inlined in
// package parser to avoid an import cycle with languages.IsQikXML.
func hasQikXMLMarkers(b []byte) bool {
lower := bytes.ToLower(b)
if !bytes.Contains(lower, []byte("<localdescref")) {
return false
}
return bytes.Contains(lower, []byte(`class="dataitem"`)) ||
bytes.Contains(lower, []byte(`class="table"`)) ||
bytes.Contains(lower, []byte(`class='dataitem'`)) ||
bytes.Contains(lower, []byte(`class='table'`))
}

// hasMyBatisMapperMarkers reports whether the content is a MyBatis mapper
// XML document — a `<mapper` root element or the MyBatis mapper DTD. Kept
// in package parser (inlined rather than calling languages.IsMyBatisMapper)
Expand Down
19 changes: 19 additions & 0 deletions internal/parser/detect_content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func ambiguityRegistry() *Registry {
// never overrides the .xml default).
r.Register(&mockExtractor{lang: "mybatis"})
r.Register(&mockExtractor{lang: "spring"})
r.Register(&mockExtractor{lang: "qikxml"})
r.Register(&mockExtractor{lang: "xml", exts: []string{".xml"}})
// .json defaults to the generic json extractor; a Shopify OS 2.0 theme
// template is content+path-routed to "liquid_json" (which claims no
Expand Down Expand Up @@ -270,3 +271,21 @@ func TestDetectLanguageContent_NilContentMatchesNameOnly(t *testing.T) {
assert.True(t, ok)
assert.Equal(t, "python", lang)
}

func TestDetectLanguageContent_QikXML(t *testing.T) {
r := ambiguityRegistry()
src := []byte(`<?xml version="1.0"?><LocalDescRef version="1.1"><name>x</name><AppObjectDesc class="DATAITEM"><name>x</name></AppObjectDesc></LocalDescRef>`)
lang, ok := r.DetectLanguageContent("DATAITEM/x.xml", src)
assert.True(t, ok)
assert.Equal(t, "qikxml", lang)

table := []byte(`<?xml version="1.0"?><LocalDescRef><name>t</name><AppObjectDesc class="TABLE"><name>t</name></AppObjectDesc></LocalDescRef>`)
lang, ok = r.DetectLanguageContent("TABLE/t.xml", table)
assert.True(t, ok)
assert.Equal(t, "qikxml", lang)

// Plain XML stays on the generic default.
lang, ok = r.DetectLanguageContent("conf/plain.xml", []byte(`<?xml version="1.0"?><config/>`))
assert.True(t, ok)
assert.Equal(t, "xml", lang)
}
277 changes: 277 additions & 0 deletions internal/parser/languages/qik_xml.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
package languages

import (
"bytes"
"encoding/xml"
"io"
"path"
"strings"

"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)

// QikXMLExtractor indexes ABA/Sabre QIK data descriptors exported as
// `.xml` under DATAITEM / TABLE trees (e.g. ITS.ABA.QIK). Documents are
// `LocalDescRef` roots with an `AppObjectDesc class="DATAITEM|TABLE"`.
//
// Surfaces:
// - file node stamped qik_class + object name
// - one symbol for the descriptor name (variable for DATAITEM, type for TABLE)
// - TABLE ColumnDesc names as nested variables
// - parentDescRef name → EdgeReferences (column → parent table hint)
//
// Shares the `.xml` extension with MyBatis / Spring / generic XML, so
// routing is content-sniffed (IsQikXML + detect_content.go). Non-QIK
// XML yields only the file node.
type QikXMLExtractor struct{}

func NewQikXMLExtractor() *QikXMLExtractor { return &QikXMLExtractor{} }

func (e *QikXMLExtractor) Language() string { return "qikxml" }
func (e *QikXMLExtractor) Extensions() []string { return nil }

// IsQikXML reports whether src is an ABA QIK LocalDescRef descriptor.
// Cheap head scan — root LocalDescRef plus AppObjectDesc class DATAITEM
// or TABLE.
func IsQikXML(src []byte) bool {
head := src
const headCap = 8 * 1024
if len(head) > headCap {
head = head[:headCap]
}
lower := bytes.ToLower(head)
if !bytes.Contains(lower, []byte("<localdescref")) {
return false
}
return bytes.Contains(lower, []byte(`class="dataitem"`)) ||
bytes.Contains(lower, []byte(`class="table"`)) ||
bytes.Contains(lower, []byte(`class='dataitem'`)) ||
bytes.Contains(lower, []byte(`class='table'`))
}

func (e *QikXMLExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
result := &parser.ExtractionResult{}
fileNode := &graph.Node{
ID: filePath,
Kind: graph.KindFile,
Name: path.Base(filePath),
FilePath: filePath,
Language: "qikxml",
}
result.Nodes = append(result.Nodes, fileNode)

if !IsQikXML(src) {
return result, nil
}

lineStarts := lineStartOffsets(src)
dec := xml.NewDecoder(bytes.NewReader(src))
dec.Strict = false

var (
objectName string
objectClass string // DATAITEM | TABLE
maxLength string
inAppObject bool
inColumns bool
inColumn bool
colName string
colMaxLen string
colLine int
depthApp int
depthCols int
depthCol int
seen = map[string]bool{}
// Stack of open element local names for path-ish text capture.
stack []string
)

emitObject := func() {
if objectName == "" || seen["obj:"+objectName] {
return
}
seen["obj:"+objectName] = true
kind := graph.KindVariable
role := strings.ToUpper(objectClass)
if role == "TABLE" {
kind = graph.KindType
}
if role == "" {
role = "DATAITEM"
}
id := filePath + "::" + objectName
meta := map[string]any{
"qik_class": role,
"qik_role": "descriptor",
}
if maxLength != "" {
meta["qik_max_length"] = maxLength
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: kind, Name: objectName,
FilePath: filePath, StartLine: 1, EndLine: 1,
Language: "qikxml", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: filePath, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: 1,
})
fileNode.Meta = map[string]any{
"qik_class": role,
"qik_object": objectName,
}
}

emitColumn := func() {
if colName == "" || seen["col:"+colName] {
return
}
seen["col:"+colName] = true
if colLine < 1 {
colLine = 1
}
id := filePath + "::col:" + colName
meta := map[string]any{"qik_role": "column"}
if objectName != "" {
meta["qik_table"] = objectName
}
if colMaxLen != "" {
meta["qik_max_length"] = colMaxLen
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindVariable, Name: colName,
FilePath: filePath, StartLine: colLine, EndLine: colLine,
Language: "qikxml", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: filePath, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: colLine,
})
if objectName != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: filePath + "::" + objectName, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: colLine,
})
}
colName, colMaxLen, colLine = "", "", 0
}

for {
tok, err := dec.Token()
if err == io.EOF || err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
local := t.Name.Local
stack = append(stack, local)
line := lineForOffset(lineStarts, int(clampOffset(dec.InputOffset(), len(src))))

switch strings.ToLower(local) {
case "appobjectdesc":
inAppObject = true
depthApp = len(stack)
if c := qikXMLAttr(t, "class"); c != "" {
objectClass = c
}
case "columns":
if inAppObject {
inColumns = true
depthCols = len(stack)
}
case "columndesc":
if inColumns {
inColumn = true
depthCol = len(stack)
colName, colMaxLen = "", ""
colLine = line
}
case "parentdescref":
// Optional reference to a parent descriptor name as child <name>.
}
case xml.EndElement:
local := t.Name.Local
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
switch strings.ToLower(local) {
case "appobjectdesc":
if len(stack) < depthApp {
inAppObject = false
}
emitObject()
case "columns":
if len(stack) < depthCols {
inColumns = false
}
case "columndesc":
if inColumn && len(stack) < depthCol {
inColumn = false
emitColumn()
}
}
case xml.CharData:
text := strings.TrimSpace(string(t))
if text == "" || len(stack) == 0 {
continue
}
cur := strings.ToLower(stack[len(stack)-1])
parent := ""
if len(stack) >= 2 {
parent = strings.ToLower(stack[len(stack)-2])
}
// Top-level LocalDescRef/name before AppObjectDesc is the object name
// in some exports; AppObjectDesc/name is authoritative when present.
if cur == "name" {
switch {
case inColumn && parent == "columndesc":
if colName == "" {
colName = text
}
case inAppObject && !inColumn && parent == "appobjectdesc":
objectName = text
case !inAppObject && (parent == "localdescref" || parent == ""):
if objectName == "" {
objectName = text
}
case parent == "parentdescref":
// Reference edge from file/object to parent name.
if objectName != "" && text != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: filePath + "::" + objectName,
To: "unresolved::qik::" + text,
Kind: graph.EdgeReferences,
FilePath: filePath,
Meta: map[string]any{"via": "qik.parentDescRef"},
})
}
}
}
if cur == "maxlength" {
if inColumn {
colMaxLen = text
} else if inAppObject && maxLength == "" {
maxLength = text
}
}
}
}
// Flush if document ended mid-element without clean end tags.
emitObject()
if inColumn {
emitColumn()
}
return result, nil
}

func qikXMLAttr(se xml.StartElement, local string) string {
for _, a := range se.Attr {
if strings.EqualFold(a.Name.Local, local) {
return strings.TrimSpace(a.Value)
}
}
return ""
}

var _ parser.Extractor = (*QikXMLExtractor)(nil)
Loading