package parser import ( "bytes" "path" "strings" ) // sniffPrefixCap bounds how much of a file the content probes scan. // Language is overwhelmingly decided by the first few lines, so // capping keeps detection cheap on large files. const sniffPrefixCap = 64 * 1024 // shebangInterpreters maps a script interpreter basename to the Gortex // language whose extractor should handle the file. Only interpreters // with a registered extractor are listed; DetectLanguageContent still // verifies the language is registered before returning it. var shebangInterpreters = map[string]string{ "sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash", "dash": "bash", "ash": "bash", "python": "python", "python2": "python", "python3": "python", "perl": "perl", "ruby": "ruby", "node": "javascript", "nodejs": "javascript", "deno": "typescript", "bun": "typescript", "php": "php", "lua": "lua", "tclsh": "tcl", "wish": "tcl", "pwsh": "powershell", "powershell": "powershell", "groovy": "groovy", "elixir": "elixir", "julia": "julia", "rscript": "r", } // sniffShebang reads the interpreter from a leading `#!` line and maps // it to a language. It handles direct interpreters (`#!/bin/bash`), // the `env` indirection (`#!/usr/bin/env python3`, including `env -S` // and `env VAR=val interp`), and trailing interpreter version // suffixes (`python3.11`). Returns ("", false) when there is no // shebang or the interpreter is unrecognised. func sniffShebang(content []byte) (string, bool) { b := bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM if len(b) < 2 || b[0] != '#' || b[1] != '!' { return "", false } line := b[2:] if nl := bytes.IndexByte(line, '\n'); nl >= 0 { line = line[:nl] } fields := strings.Fields(string(line)) if len(fields) == 0 { return "", false } interp := strings.ToLower(path.Base(fields[0])) if interp == "env" { // `#!/usr/bin/env [-S] [VAR=val] interp` — the real // interpreter is the first token that is neither an env flag // nor a NAME=VALUE assignment. interp = "" for _, f := range fields[1:] { if strings.HasPrefix(f, "-") || strings.Contains(f, "=") { continue } interp = strings.ToLower(path.Base(f)) break } if interp == "" { return "", false } } if lang, ok := shebangInterpreters[interp]; ok { return lang, true } if lang, ok := shebangInterpreters[trimInterpVersion(interp)]; ok { return lang, true } return "", false } // trimInterpVersion strips a trailing version suffix from an // interpreter name: "python3.11" -> "python", "ruby2" -> "ruby". func trimInterpVersion(interp string) string { end := len(interp) for end > 0 { c := interp[end-1] if (c >= '0' && c <= '9') || c == '.' { end-- continue } break } return interp[:end] } // sniffAmbiguous refines the language of a file whose extension maps // to more than one plausible language. ext is the file extension // (with leading dot). It returns (lang, true) when the content // clearly indicates a specific language, and ("", false) when the // extension is not ambiguous or the probe is inconclusive — the // caller then keeps the extension's default mapping. func sniffAmbiguous(filePath, ext string, content []byte) (string, bool) { if len(content) == 0 { return "", false } probe := content if len(probe) > sniffPrefixCap { probe = probe[:sniffPrefixCap] } switch strings.ToLower(ext) { case ".json": // A Shopify OS 2.0 JSON template / section group (templates/*.json, // sections/*.json) wires sections by `type`; every other .json keeps // the generic JSON default (package.json, tsconfig.json, …). if isShopifyThemeJSONPath(filePath) && hasShopifyTemplateMarkers(probe) { return "liquid_json", true } case ".h": // C / C++ / Objective-C header. if hasObjCMarkers(probe) { return "objc", true } if hasCppMarkers(probe) { return "cpp", true } case ".inc": // A contested extension: assembly, PHP, and Pascal each claim it by // default. Reroute to C only when the fragment carries an unmistakable // C signal (a C preprocessor directive, or a C block comment alongside // braces) and none of the other claimants' markers — so an X-macro / // generated-C ".inc" is parsed as C while PHP / Pascal / asm includes // keep their default language untouched. if hasCFragmentMarkers(probe) && !hasPHPMarkers(probe) && !hasPascalMarkers(probe) { return "c", true } case ".m": // Objective-C / MATLAB / Mathematica source. if hasObjCMarkers(probe) { return "objc", true } if hasMatlabMarkers(probe) { return "matlab", true } if hasMathematicaMarkers(probe) { return "mathematica", true } case ".xml": // A MyBatis mapper / Spring beans 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 } } return "", false } // isShopifyThemeJSONPath reports whether a path lives under a Shopify theme's // `templates/` or `sections/` directory — the OS 2.0 JSON-template locations. func isShopifyThemeJSONPath(filePath string) bool { p := "/" + strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), "/") return strings.Contains(p, "/templates/") || strings.Contains(p, "/sections/") } // hasShopifyTemplateMarkers reports whether the JSON content carries the OS 2.0 // section-group shape — a `"sections"` object plus a `"type"` key naming a // section file. A cheap byte pre-filter; the extractor parses and validates. func hasShopifyTemplateMarkers(b []byte) bool { return bytes.Contains(b, []byte(`"sections"`)) && bytes.Contains(b, []byte(`"type"`)) } // hasMyBatisMapperMarkers reports whether the content is a MyBatis mapper // XML document — a ``/`` // DTD-style document. Inlined in package parser to avoid an import cycle // with languages.IsSpringBeansXML. func hasSpringBeansMarkers(b []byte) bool { return bytes.Contains(b, []byte("springframework.org/schema/beans")) || (bytes.Contains(b, []byte("