Skip to content

Commit 1dfa606

Browse files
author
Kevin Allioli
committed
fix: rewrite language detection with regex patterns + Pygments fallback
Replace the single Pygments guess (unreliable under 100 chars) with a two-stage approach: 1. Regex patterns on strong syntactic markers (fires from 30 chars) 2. Pygments heuristic as fallback for longer content Fixes detection of Python, JS, TS, Go, SQL, Bash, Rust, CSS, YAML, JSON, Dockerfile from typical short snippets.
1 parent 90e9190 commit 1dfa606

1 file changed

Lines changed: 83 additions & 57 deletions

File tree

detect.py

Lines changed: 83 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,114 @@
11
"""
2-
Language detection using Pygments.
2+
Language detection.
3+
4+
Two-stage strategy:
5+
1. Regex patterns for clear syntactic markers (fires from 30 chars).
6+
2. Pygments heuristic for longer content (100+ chars) as fallback.
37
48
Returns a language slug compatible with the LANGUAGES list in main.py,
5-
or None if detection confidence is too low.
9+
or None if not confident enough.
610
"""
711

12+
import re
13+
814
from pygments.lexers import guess_lexer
915
from pygments.util import ClassNotFound
1016

11-
# Map Pygments lexer aliases → our language slugs
17+
# Ordered list of (language_slug, compiled_pattern).
18+
# Patterns look for strong, unambiguous markers — NOT generic keywords.
19+
_PATTERNS: list[tuple[str, re.Pattern]] = [
20+
# Shebangs
21+
("bash", re.compile(r"^#!\s*/(?:usr/)?(?:local/)?bin/(?:bash|sh|zsh)", re.M)),
22+
# JSON — starts with { or [ followed by quoted key or value
23+
("json", re.compile(r'^\s*[\[{]\s*\n?\s*"', re.M)),
24+
# HTML
25+
("html", re.compile(r"<!DOCTYPE\s+html|<html[\s>]|<head[\s>]|<body[\s>]", re.I)),
26+
# SQL — SELECT/INSERT/UPDATE/DELETE + FROM/INTO/SET
27+
("sql", re.compile(r"\b(?:SELECT|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b", re.I)),
28+
# CSS — require known CSS properties to avoid false positives
29+
("css", re.compile(r"\b(?:color|margin|padding|font-|background|display|border|width|height|flex|grid)\s*:", re.I)),
30+
# YAML front matter or key: value blocks
31+
("yaml", re.compile(r"^---\s*\n|^[a-z_][a-z0-9_]*:\s+\S", re.M)),
32+
# TypeScript (must come before JavaScript — stricter markers)
33+
("typescript", re.compile(r":\s*(?:string|number|boolean|void|any|never)\b|interface\s+\w+\s*\{|type\s+\w+\s*=")),
34+
# JavaScript
35+
("javascript", re.compile(r"\bconsole\.[a-z]+\s*\(|require\s*\(|(?:const|let|var)\s+\w+\s*=|=>\s*[{\w]")),
36+
# Python
37+
("python", re.compile(r"^\s*(?:def |class |import |from \w+ import |elif |@\w+)\b|print\s*\(", re.M)),
38+
# Go
39+
("go", re.compile(r"^package\s+\w+|^import\s+\"|\bfmt\.\w+\s*\(|func\s+\w+\s*\(", re.M)),
40+
# Rust (before CSS — brace syntax could confuse CSS pattern)
41+
("rust", re.compile(r"\bfn\s+\w+\s*\(|let\s+mut\s+|use\s+std::|impl\s+\w+")),
42+
# Ruby
43+
("ruby", re.compile(r"^\s*(?:def |end\b|require ['\"]|puts |attr_(?:accessor|reader|writer))", re.M)),
44+
# Java / Kotlin (shared marker first, then disambiguate)
45+
("java", re.compile(r"public\s+(?:static\s+)?(?:class|void|int|String)\b|System\.out\.print")),
46+
("kotlin", re.compile(r"\bfun\s+\w+\s*\(|val\s+\w+\s*:|var\s+\w+\s*:|println\s*\(")),
47+
# Bash (non-shebang)
48+
("bash", re.compile(r'^\s*(?:echo\s|export\s|if\s*\[|fi\b|source\s|curl\s|apt\s)', re.M)),
49+
# Dockerfile
50+
("dockerfile", re.compile(r"^(?:FROM|RUN|CMD|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|WORKDIR)\s", re.M)),
51+
# TOML
52+
("toml", re.compile(r"^\[[\w.]+\]\s*$|^\w+\s*=\s*(?:true|false|\d+|\")", re.M)),
53+
# XML
54+
("xml", re.compile(r"<\?xml\s|<[a-z][a-z0-9]*(?:\s[^>]*)?>.*</[a-z]", re.I | re.S)),
55+
# Markdown
56+
("markdown", re.compile(r"^#{1,6}\s\w|^\*\*\w|\[.+\]\(.+\)|^[-*]\s\w", re.M)),
57+
# Diff / patch
58+
("diff", re.compile(r"^(?:---|\+\+\+|@@\s+-\d+)", re.M)),
59+
]
60+
61+
# Map Pygments lexer aliases → our language slugs (fallback for long content)
1262
_ALIAS_MAP: dict[str, str] = {
13-
"bash": "bash",
14-
"sh": "bash",
15-
"shell": "bash",
16-
"zsh": "bash",
17-
"c": "c",
18-
"cpp": "cpp",
19-
"c++": "cpp",
20-
"csharp": "csharp",
21-
"c#": "csharp",
22-
"css": "css",
23-
"diff": "diff",
24-
"patch": "diff",
25-
"docker": "dockerfile",
26-
"dockerfile": "dockerfile",
27-
"go": "go",
28-
"html": "html",
29-
"java": "java",
30-
"js": "javascript",
31-
"javascript": "javascript",
32-
"json": "json",
33-
"kotlin": "kotlin",
34-
"lua": "lua",
35-
"make": "makefile",
36-
"makefile": "makefile",
37-
"markdown": "markdown",
38-
"md": "markdown",
39-
"php": "php",
40-
"python": "python",
41-
"python3": "python",
42-
"py": "python",
43-
"rb": "ruby",
44-
"ruby": "ruby",
45-
"rust": "rust",
46-
"sql": "sql",
47-
"swift": "swift",
48-
"toml": "toml",
49-
"ts": "typescript",
50-
"typescript": "typescript",
51-
"xml": "xml",
52-
"yaml": "yaml",
53-
"yml": "yaml",
63+
"bash": "bash", "sh": "bash", "shell": "bash", "zsh": "bash",
64+
"c": "c", "cpp": "cpp", "c++": "cpp",
65+
"csharp": "csharp", "c#": "csharp",
66+
"css": "css", "diff": "diff", "patch": "diff",
67+
"docker": "dockerfile", "dockerfile": "dockerfile",
68+
"go": "go", "html": "html", "java": "java",
69+
"js": "javascript", "javascript": "javascript",
70+
"json": "json", "kotlin": "kotlin", "lua": "lua",
71+
"make": "makefile", "makefile": "makefile",
72+
"markdown": "markdown", "md": "markdown",
73+
"php": "php", "python": "python", "python3": "python", "py": "python",
74+
"rb": "ruby", "ruby": "ruby", "rust": "rust",
75+
"sql": "sql", "swift": "swift", "toml": "toml",
76+
"ts": "typescript", "typescript": "typescript",
77+
"xml": "xml", "yaml": "yaml", "yml": "yaml",
5478
}
5579

56-
# Lexers that Pygments over-triggers on plain text
5780
_NOISY_LEXERS = {"text only", "plain text", "tex", "restructuredtext"}
58-
59-
# Minimum content length — Pygments needs enough tokens to be confident
60-
_MIN_LENGTH = 100
81+
_PYGMENTS_MIN = 100
6182

6283

6384
def detect_language(content: str) -> str | None:
6485
"""Return a language slug or None if not confident enough."""
65-
if len(content.strip()) < _MIN_LENGTH:
86+
text = content.strip()
87+
if not text:
88+
return None
89+
90+
# Stage 1 — fast regex patterns (works from 30 chars)
91+
if len(text) >= 30:
92+
for slug, pattern in _PATTERNS:
93+
if pattern.search(text):
94+
return slug
95+
96+
# Stage 2 — Pygments heuristic (needs more content)
97+
if len(text) < _PYGMENTS_MIN:
6698
return None
6799

68100
try:
69-
lexer = guess_lexer(content)
101+
lexer = guess_lexer(text)
70102
except ClassNotFound:
71103
return None
72104

73105
name = lexer.name.lower()
74-
75106
if name in _NOISY_LEXERS:
76107
return None
77108

78-
# Check aliases first, then the lexer name itself
79109
for alias in lexer.aliases:
80110
slug = _ALIAS_MAP.get(alias.lower())
81111
if slug:
82112
return slug
83113

84-
slug = _ALIAS_MAP.get(name)
85-
if slug:
86-
return slug
87-
88-
return None
114+
return _ALIAS_MAP.get(name)

0 commit comments

Comments
 (0)