use crate::assemble::Stage;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HostPos {
pub line: u32,
pub col: u32,
}
pub struct Embedded {
pub source: String,
pub stage: Stage,
pub has_entry: bool,
pub name: Option<String>,
pub has_interp: bool,
pub line_map: Vec<HostPos>,
}
impl Embedded {
pub fn map(&self, line: u32, col: u32) -> HostPos {
let idx = (line.saturating_sub(1) as usize).min(self.line_map.len().saturating_sub(1));
let base = self
.line_map
.get(idx)
.copied()
.unwrap_or(HostPos { line: 1, col: 1 });
HostPos {
line: base.line,
col: base.col.saturating_add(col.saturating_sub(1)),
}
}
}
pub fn is_js_ts(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("ts" | "tsx" | "mts" | "cts" | "js" | "jsx" | "mjs" | "cjs")
)
}
pub fn extract(source: &str) -> Vec<Embedded> {
let cs: Vec<char> = source.chars().collect();
let mut cur = Cursor::new(&cs);
let mut out = Vec::new();
while let Some(c) = cur.peek() {
match c {
'/' if cur.peek2() == Some('/') => skip_line_comment(&mut cur),
'/' if cur.peek2() == Some('*') => {
let anchor = cur.i;
if skip_block_comment(&mut cur) {
skip_ws(&mut cur);
if cur.peek() == Some('`') {
cur.bump();
push_embedded(&mut out, &mut cur, binding_before(&cs, anchor));
}
}
}
'\'' | '"' => skip_string(&mut cur, c),
'`' => skip_template(&mut cur), c if is_ident_start(c) => {
let anchor = cur.i;
let ident = read_ident(&mut cur);
if ident == "glsl" && !preceded_by_dot(&cs, anchor) {
skip_ws(&mut cur);
if cur.peek() == Some('`') {
cur.bump();
push_embedded(&mut out, &mut cur, binding_before(&cs, anchor));
}
}
}
_ => {
cur.bump();
}
}
}
out
}
fn push_embedded(out: &mut Vec<Embedded>, cur: &mut Cursor, name: Option<String>) {
let (source, line_map, has_interp) = read_template(cur);
let (stage, has_entry) = infer_stage(name.as_deref(), &source);
out.push(Embedded {
source,
stage,
has_entry,
name,
has_interp,
line_map,
});
}
fn read_template(cur: &mut Cursor) -> (String, Vec<HostPos>, bool) {
let mut out = String::new();
let mut line_map = vec![cur.pos()]; let mut has_interp = false;
while let Some(c) = cur.peek() {
match c {
'`' => {
cur.bump();
break;
}
'\\' => {
cur.bump();
match cur.peek() {
Some('\r') => {
let fresh = line_empty(&out);
cur.bump();
if cur.peek() == Some('\n') {
cur.bump();
}
if fresh {
reanchor(&mut line_map, cur.pos());
}
}
Some('\n') => {
let fresh = line_empty(&out);
cur.bump();
if fresh {
reanchor(&mut line_map, cur.pos());
}
}
Some('`') => {
out.push('`');
cur.bump();
}
Some('$') => {
out.push('$');
cur.bump();
}
Some('\\') => {
out.push('\\');
cur.bump();
}
Some(other) => {
out.push('\\');
out.push(other);
cur.bump();
}
None => out.push('\\'),
}
}
'$' if cur.peek2() == Some('{') => {
has_interp = true;
blank_interpolation(cur, &mut out, &mut line_map);
}
'\n' => {
out.push('\n');
cur.bump();
line_map.push(cur.pos());
}
'\r' => {
out.push('\r');
cur.bump();
}
_ => {
out.push(c);
cur.bump();
}
}
}
(out, line_map, has_interp)
}
fn line_empty(out: &str) -> bool {
out.rsplit('\n').next().is_none_or(str::is_empty)
}
fn reanchor(line_map: &mut [HostPos], pos: HostPos) {
if let Some(last) = line_map.last_mut() {
*last = pos;
}
}
fn blank_interpolation(cur: &mut Cursor, out: &mut String, line_map: &mut Vec<HostPos>) {
cur.bump(); out.push(' ');
cur.bump(); out.push(' ');
let mut depth = 1i32;
while let Some(c) = cur.peek() {
match c {
'{' => {
depth += 1;
cur.bump();
out.push(' ');
}
'}' => {
depth -= 1;
cur.bump();
out.push(' ');
if depth == 0 {
break;
}
}
'\'' | '"' => blank_string(cur, out, line_map, c),
'`' => blank_nested_template(cur, out, line_map),
'\n' => {
cur.bump();
out.push('\n');
line_map.push(cur.pos());
}
'\r' => {
cur.bump();
out.push('\r');
}
_ => {
cur.bump();
out.push(' ');
}
}
}
}
fn blank_string(cur: &mut Cursor, out: &mut String, line_map: &mut Vec<HostPos>, quote: char) {
cur.bump(); out.push(' ');
while let Some(c) = cur.peek() {
match c {
'\\' => {
cur.bump();
out.push(' ');
if cur.peek().is_some() {
cur.bump();
out.push(' ');
}
}
c if c == quote => {
cur.bump();
out.push(' ');
break;
}
'\n' => {
cur.bump();
out.push('\n');
line_map.push(cur.pos());
}
_ => {
cur.bump();
out.push(' ');
}
}
}
}
fn blank_nested_template(cur: &mut Cursor, out: &mut String, line_map: &mut Vec<HostPos>) {
cur.bump(); out.push(' ');
while let Some(c) = cur.peek() {
match c {
'\\' => {
cur.bump();
out.push(' ');
if let Some(n) = cur.peek() {
cur.bump();
if n == '\n' {
out.push('\n');
line_map.push(cur.pos());
} else {
out.push(' ');
}
}
}
'`' => {
cur.bump();
out.push(' ');
break;
}
'\n' => {
cur.bump();
out.push('\n');
line_map.push(cur.pos());
}
'\r' => {
cur.bump();
out.push('\r');
}
_ => {
cur.bump();
out.push(' ');
}
}
}
}
const VERTEX_BUILTINS: &[&str] = &[
"gl_Position",
"gl_PointSize",
"gl_VertexID",
"gl_InstanceID",
];
const FRAGMENT_BUILTINS: &[&str] = &[
"gl_FragCoord",
"gl_FragDepth",
"gl_FrontFacing",
"gl_PointCoord",
"discard",
"dFdx",
"dFdy",
"fwidth",
];
const COMPUTE_BUILTINS: &[&str] = &[
"gl_GlobalInvocationID",
"gl_LocalInvocationID",
"gl_LocalInvocationIndex",
"gl_WorkGroupID",
"gl_NumWorkGroups",
];
fn infer_stage(name: Option<&str>, src: &str) -> (Stage, bool) {
let code = strip_comments(src);
let has_entry = has_entry_point(&code);
let stage = builtin_stage(&code)
.or_else(|| name_stage(name))
.unwrap_or(Stage::Fragment);
(stage, has_entry)
}
fn builtin_stage(code: &str) -> Option<Stage> {
let uses = |set: &[&str]| set.iter().any(|b| contains_word(code, b));
if code.contains("local_size_x") || uses(COMPUTE_BUILTINS) {
Some(Stage::Compute)
} else if uses(VERTEX_BUILTINS) {
Some(Stage::Vertex)
} else if uses(FRAGMENT_BUILTINS) {
Some(Stage::Fragment)
} else {
None
}
}
fn name_stage(name: Option<&str>) -> Option<Stage> {
let n = name?.to_ascii_lowercase();
if n.contains("vert") || n == "vs" || n.ends_with("vs") || n.ends_with("_vs") {
Some(Stage::Vertex)
} else if n.contains("frag") || n == "fs" || n.ends_with("fs") || n.ends_with("_fs") {
Some(Stage::Fragment)
} else if n.contains("comp") || n.contains("compute") {
Some(Stage::Compute)
} else {
None
}
}
fn has_entry_point(code: &str) -> bool {
let bytes = code.as_bytes();
let mut from = 0;
while let Some(rel) = code[from..].find("main") {
let start = from + rel;
let end = start + 4;
let left = start == 0 || !is_word_byte(bytes[start - 1]);
let right = end == bytes.len() || !is_word_byte(bytes[end]);
if left && right {
let before = code[..start].trim_end();
if before.ends_with("void") {
let vstart = before.len() - 4;
if vstart == 0 || !is_word_byte(before.as_bytes()[vstart - 1]) {
return true;
}
}
}
from = start + 1;
}
false
}
fn binding_before(cs: &[char], i: usize) -> Option<String> {
let mut i = skip_ws_back(cs, i);
if i == 0 {
return None;
}
let op = cs[i - 1];
if op != '=' && op != ':' {
return None;
}
if op == '=' && i >= 2 && matches!(cs[i - 2], '=' | '!' | '<' | '>') {
return None;
}
i -= 1;
i = skip_ws_back(cs, i);
let end = i;
while i > 0 && is_ident_char(cs[i - 1]) {
i -= 1;
}
if i == end {
return None;
}
let name: String = cs[i..end].iter().collect();
is_ident_start(name.chars().next()?).then_some(name)
}
fn preceded_by_dot(cs: &[char], i: usize) -> bool {
let i = skip_ws_back(cs, i);
i > 0 && cs[i - 1] == '.'
}
fn skip_ws_back(cs: &[char], mut i: usize) -> usize {
while i > 0 && cs[i - 1].is_whitespace() {
i -= 1;
}
i
}
struct Cursor<'a> {
cs: &'a [char],
i: usize,
line: u32,
col: u32,
}
impl<'a> Cursor<'a> {
fn new(cs: &'a [char]) -> Self {
Cursor {
cs,
i: 0,
line: 1,
col: 1,
}
}
fn peek(&self) -> Option<char> {
self.cs.get(self.i).copied()
}
fn peek2(&self) -> Option<char> {
self.cs.get(self.i + 1).copied()
}
fn bump(&mut self) -> Option<char> {
let c = self.cs.get(self.i).copied()?;
self.i += 1;
if c == '\n' {
self.line += 1;
self.col = 1;
} else {
self.col += 1;
}
Some(c)
}
fn pos(&self) -> HostPos {
HostPos {
line: self.line,
col: self.col,
}
}
}
fn read_ident(cur: &mut Cursor) -> String {
let mut s = String::new();
while let Some(c) = cur.peek() {
if is_ident_char(c) {
s.push(c);
cur.bump();
} else {
break;
}
}
s
}
fn skip_ws(cur: &mut Cursor) {
while matches!(cur.peek(), Some(c) if c.is_whitespace()) {
cur.bump();
}
}
fn skip_line_comment(cur: &mut Cursor) {
cur.bump(); cur.bump(); while let Some(c) = cur.peek() {
if c == '\n' {
break;
}
cur.bump();
}
}
fn skip_block_comment(cur: &mut Cursor) -> bool {
cur.bump(); cur.bump(); let start = cur.i;
while let Some(c) = cur.peek() {
if c == '*' && cur.peek2() == Some('/') {
let content: String = cur.cs[start..cur.i].iter().collect();
cur.bump(); cur.bump(); return content.trim().eq_ignore_ascii_case("glsl");
}
cur.bump();
}
false }
fn skip_string(cur: &mut Cursor, quote: char) {
cur.bump(); while let Some(c) = cur.peek() {
match c {
'\\' => {
cur.bump();
cur.bump();
}
c if c == quote => {
cur.bump();
return;
}
'\n' => return, _ => {
cur.bump();
}
}
}
}
fn skip_template(cur: &mut Cursor) {
cur.bump(); while let Some(c) = cur.peek() {
match c {
'\\' => {
cur.bump();
cur.bump();
}
'`' => {
cur.bump();
return;
}
'$' if cur.peek2() == Some('{') => skip_interpolation(cur),
_ => {
cur.bump();
}
}
}
}
fn skip_interpolation(cur: &mut Cursor) {
cur.bump(); cur.bump(); let mut depth = 1i32;
while let Some(c) = cur.peek() {
match c {
'{' => {
depth += 1;
cur.bump();
}
'}' => {
depth -= 1;
cur.bump();
if depth == 0 {
return;
}
}
'\'' | '"' => skip_string(cur, c),
'`' => skip_template(cur),
'\\' => {
cur.bump();
cur.bump();
}
_ => {
cur.bump();
}
}
}
}
fn is_ident_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_' || c == '$'
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '$'
}
fn contains_word(text: &str, word: &str) -> bool {
let bytes = text.as_bytes();
let mut from = 0;
while let Some(rel) = text[from..].find(word) {
let start = from + rel;
let end = start + word.len();
let left = start == 0 || !is_word_byte(bytes[start - 1]);
let right = end == bytes.len() || !is_word_byte(bytes[end]);
if left && right {
return true;
}
from = start + 1;
}
false
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn strip_comments(src: &str) -> String {
let cs: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut i = 0;
while i < cs.len() {
if cs[i] == '/' && cs.get(i + 1) == Some(&'/') {
while i < cs.len() && cs[i] != '\n' {
i += 1;
}
} else if cs[i] == '/' && cs.get(i + 1) == Some(&'*') {
i += 2;
while i + 1 < cs.len() && !(cs[i] == '*' && cs[i + 1] == '/') {
i += 1;
}
i = (i + 2).min(cs.len());
} else {
out.push(cs[i]);
i += 1;
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn extracts_a_tagged_template_and_its_binding() {
let src = "const fs = glsl`#version 300 es\nvoid main() {}\n`;\n";
let e = extract(src);
assert_eq!(e.len(), 1);
assert_eq!(e[0].name.as_deref(), Some("fs"));
assert_eq!(e[0].stage, Stage::Fragment);
assert!(e[0].has_entry);
assert!(!e[0].has_interp);
assert!(e[0].source.starts_with("#version 300 es"));
}
#[test]
fn maps_first_line_column_past_the_backtick() {
let src = "const fs = glsl`#version 300 es\nout vec4 c;\nvoid main(){ c = nope; }`;\n";
let e = &extract(src)[0];
assert_eq!(e.map(1, 1), HostPos { line: 1, col: 17 });
assert_eq!(e.map(3, 14), HostPos { line: 3, col: 14 });
}
#[test]
fn recognizes_the_block_comment_marker_form() {
let src = "export const src =\n /* glsl */ `#version 300 es\nvoid main() {}\n`;\n";
let e = extract(src);
assert_eq!(e.len(), 1);
assert_eq!(e[0].name.as_deref(), Some("src"));
assert_eq!(e[0].line_map[0].line, 2);
}
#[test]
fn infers_vertex_from_gl_position() {
let src = "const s = glsl`#version 300 es\nvoid main() { gl_Position = vec4(0.0); }`;\n";
let e = &extract(src)[0];
assert_eq!(e.stage, Stage::Vertex);
assert!(e.has_entry);
}
#[test]
fn infers_vertex_from_a_vertex_only_builtin_without_gl_position() {
let src = "const update = glsl`#version 300 es\nin float a;\nout float b;\n\
void main() { b = a * float(gl_VertexID); }`;\n";
let e = &extract(src)[0];
assert_eq!(e.stage, Stage::Vertex);
assert!(e.has_entry);
}
#[test]
fn content_stage_beats_a_misleading_binding_name() {
let src = "const vs = glsl`#version 300 es\nout vec4 c;\n\
void main() { c = gl_FragCoord; }`;\n";
assert_eq!(extract(src)[0].stage, Stage::Fragment);
}
#[test]
fn a_chunk_without_main_is_wrapped_under_its_builtin_stage() {
let plain = "const chunk = glsl`float rand(vec2 p) { return 0.0; }`;\n";
let e = &extract(plain)[0];
assert!(!e.has_entry);
assert_eq!(e.stage, Stage::Fragment);
let vtx = "const vid = glsl`float vid() { return float(gl_VertexID); }`;\n";
let e = &extract(vtx)[0];
assert!(!e.has_entry);
assert_eq!(e.stage, Stage::Vertex);
}
#[test]
fn main_in_a_comment_is_not_an_entry_point() {
let src = "const chunk = glsl`// main helpers below\nfloat rand() { return 0.0; }`;\n";
assert!(!extract(src)[0].has_entry);
}
#[test]
fn interpolation_is_flagged_and_blanked_preserving_lines() {
let src = "const fs = glsl`#version 300 es\n${chunk}\nvoid main() {}\n`;\n";
let e = &extract(src)[0];
assert!(e.has_interp);
let lines: Vec<&str> = e.source.lines().collect();
assert_eq!(lines[0], "#version 300 es");
assert!(lines[1].trim().is_empty());
assert_eq!(lines[2], "void main() {}");
assert_eq!(e.map(3, 1), HostPos { line: 3, col: 1 });
}
#[test]
fn leading_line_continuation_drops_the_first_newline() {
let src = "const fs = glsl`\\\n#version 300 es\nvoid main() {}`;\n";
let e = &extract(src)[0];
assert!(e.source.starts_with("#version 300 es"));
assert_eq!(e.map(1, 1).line, 2);
}
#[test]
fn ignores_glsl_as_a_substring_or_member() {
let src = "const a = glslify`x`;\nconst b = x.glsl`y`;\nconst myglsl = 1;\n";
assert!(extract(src).is_empty());
}
#[test]
fn skips_backticks_inside_ordinary_strings() {
let src = "const s = \"a `glsl` b\";\nconst t = 'more `glsl`';\n";
assert!(extract(src).is_empty());
}
#[test]
fn finds_multiple_templates_in_one_file() {
let src = "const vs = glsl`#version 300 es\nvoid main(){ gl_Position = vec4(0.0); }`;\n\
const fs = glsl`#version 300 es\nout vec4 c;\nvoid main(){ c = vec4(1.0); }`;\n";
let e = extract(src);
assert_eq!(e.len(), 2);
assert_eq!(e[0].stage, Stage::Vertex);
assert_eq!(e[1].stage, Stage::Fragment);
}
#[test]
fn fixture_tagged_ts_extracts_two_shaders() {
let e = extract(include_str!("../tests/fixtures/tagged.ts"));
assert_eq!(e.len(), 2);
assert_eq!(e[0].name.as_deref(), Some("fs"));
assert_eq!(e[0].stage, Stage::Fragment);
assert_eq!(e[1].name.as_deref(), Some("vs"));
assert_eq!(e[1].stage, Stage::Vertex);
assert_eq!(e[0].line_map[0].line, 4);
}
}