|
1 | 1 | """ |
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. |
3 | 7 |
|
4 | 8 | 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. |
6 | 10 | """ |
7 | 11 |
|
| 12 | +import re |
| 13 | + |
8 | 14 | from pygments.lexers import guess_lexer |
9 | 15 | from pygments.util import ClassNotFound |
10 | 16 |
|
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) |
12 | 62 | _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", |
54 | 78 | } |
55 | 79 |
|
56 | | -# Lexers that Pygments over-triggers on plain text |
57 | 80 | _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 |
61 | 82 |
|
62 | 83 |
|
63 | 84 | def detect_language(content: str) -> str | None: |
64 | 85 | """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: |
66 | 98 | return None |
67 | 99 |
|
68 | 100 | try: |
69 | | - lexer = guess_lexer(content) |
| 101 | + lexer = guess_lexer(text) |
70 | 102 | except ClassNotFound: |
71 | 103 | return None |
72 | 104 |
|
73 | 105 | name = lexer.name.lower() |
74 | | - |
75 | 106 | if name in _NOISY_LEXERS: |
76 | 107 | return None |
77 | 108 |
|
78 | | - # Check aliases first, then the lexer name itself |
79 | 109 | for alias in lexer.aliases: |
80 | 110 | slug = _ALIAS_MAP.get(alias.lower()) |
81 | 111 | if slug: |
82 | 112 | return slug |
83 | 113 |
|
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