Skip to main content

powerio_dist/glm/
preprocess.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::ops::Range;
3
4use powerio_core::{Error, Source, SourceBuffer, SourceId, SourceSpan};
5
6use crate::diagnostics::codes;
7
8const MAX_BYTES: usize = 64 << 20;
9const MAX_LINE: usize = 1 << 20;
10const MAX_ORIGINS: usize = 1 << 20;
11const MAX_FILES: usize = 4096;
12const MAX_DEPTH: usize = 64;
13const MAX_DEFINES: usize = 4096;
14
15/// Loader conventions qualified against the pinned engine versions.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum GlmDialect {
19    #[default]
20    Pnnl5_3,
21    Arras4_3,
22}
23
24/// Explicit preprocessing inputs. No process environment or simulator globals
25/// are consulted. Callers must supply the globals required by their profile.
26#[derive(Clone, Debug, Default)]
27pub struct PreprocessOptions {
28    pub dialect: GlmDialect,
29    pub defines: BTreeMap<String, String>,
30    /// Permit repeated includes, but never an include cycle.
31    pub reinclude: bool,
32}
33
34/// A contiguous expansion segment and the source bytes responsible for it.
35#[derive(Clone, Debug)]
36pub struct ExpansionOrigin {
37    pub expanded: Range<usize>,
38    pub original: SourceSpan,
39    /// True for a byte-for-byte copy. False for a variable substitution or a
40    /// separator inserted at an include boundary.
41    pub exact: bool,
42}
43
44/// Preprocessed text, distinct from the retained original project. This is not
45/// an electrically interpreted network and must never replace source retention.
46#[derive(Clone, Debug)]
47pub struct PreparedGlm {
48    pub source: Source,
49    text: String,
50    origins: Vec<ExpansionOrigin>,
51    /// Buffers visited in first-use order; excludes unused supplied buffers.
52    dependencies: Vec<SourceId>,
53    absolute_include: bool,
54}
55
56impl PreparedGlm {
57    #[must_use]
58    pub fn text(&self) -> &str {
59        &self.text
60    }
61
62    #[must_use]
63    pub fn origins(&self) -> &[ExpansionOrigin] {
64        &self.origins
65    }
66
67    #[must_use]
68    pub fn dependencies(&self) -> &[SourceId] {
69        &self.dependencies
70    }
71
72    pub(super) const fn has_absolute_include(&self) -> bool {
73        self.absolute_include
74    }
75
76    /// Translate an expanded byte range to its original source segments.
77    /// Substitution segments point to the complete `${name}` reference.
78    /// Empty or out-of-bounds ranges return an empty vector.
79    #[must_use]
80    pub fn original_spans(&self, range: Range<usize>) -> Vec<SourceSpan> {
81        self.original_span_iter(range).collect()
82    }
83
84    pub(crate) fn original_span_iter(
85        &self,
86        mut range: Range<usize>,
87    ) -> impl Iterator<Item = SourceSpan> + '_ {
88        if range.start >= range.end || range.end > self.text.len() {
89            range = 0..0;
90        }
91        let first = self
92            .origins
93            .partition_point(|o| o.expanded.end <= range.start);
94        self.origins[first..]
95            .iter()
96            .take_while(move |o| o.expanded.start < range.end)
97            .map(move |o| {
98                if o.exact {
99                    let start = o.original.byte_start()
100                        + (range.start.max(o.expanded.start) - o.expanded.start) as u64;
101                    let end = o.original.byte_start()
102                        + (range.end.min(o.expanded.end) - o.expanded.start) as u64;
103                    SourceSpan::new(o.original.source().clone(), start, end)
104                        .expect("intersected expansion ranges are ordered")
105                } else {
106                    o.original.clone()
107                }
108            })
109    }
110}
111
112/// Expand local quoted includes containing complete declarations,
113/// `#define NAME=value`, `#ifdef`, `#ifndef`,
114/// string-comparison `#if`, `#else`, `#endif`, and explicit `${NAME}` variables.
115///
116/// Substitution is one pass, including within quoted values. Defines become
117/// visible in source order, also across includes. Inactive branches are removed
118/// before syntax parsing. `#set` and `#warning` remain inert directive lines for
119/// subsequent interpretation; they do not populate the explicit define table.
120/// Preprocessing switches `reinclude`, and Arras `literal_if` and
121/// `relax_undefined_if`, are also applied as explicit boolean settings.
122/// Other active directives fail explicitly. In particular, this function never
123/// executes code, accesses the process environment, or fetches remote includes.
124/// Include paths resolve relative to the primary file, matching an engine
125/// launched in that directory; implicit `GLPATH` searches are not performed.
126///
127/// Bounds: 64 MiB processed input and expanded output, 1 MiB per line/definition,
128/// 4096 files/defines, one million source-map segments, and 64 include or
129/// conditional levels. Duplicate includes require `reinclude`; cycles fail.
130pub fn preprocess_source(source: Source, options: PreprocessOptions) -> Result<PreparedGlm, Error> {
131    let result: Result<_, Error> = (|| {
132        let primary = source.primary_buffer()?;
133        let define_bytes = options.defines.iter().fold(0usize, |n, (k, v)| {
134            n.saturating_add(k.len()).saturating_add(v.len())
135        });
136        let mut context = Context {
137            source: &source,
138            include_base: primary.clone(),
139            literal_if: options.dialect == GlmDialect::Arras4_3,
140            relax_undefined_if: false,
141            options,
142            output: String::new(),
143            origins: Vec::new(),
144            dependencies: Vec::new(),
145            absolute_include: false,
146            visited: BTreeSet::new(),
147            active: BTreeSet::new(),
148            input_bytes: 0,
149            define_bytes,
150            boundary: Boundary::default(),
151        };
152        context.check_defines(&primary, 0..0)?;
153        context.file(&primary, 0)?;
154        Ok((
155            context.output,
156            context.origins,
157            context.dependencies,
158            context.absolute_include,
159        ))
160    })();
161    match result {
162        Ok((text, origins, dependencies, absolute_include)) => Ok(PreparedGlm {
163            source,
164            text,
165            origins,
166            dependencies,
167            absolute_include,
168        }),
169        Err(error) => Err(error.with_source(source)),
170    }
171}
172
173struct Context<'a> {
174    source: &'a Source,
175    include_base: SourceBuffer,
176    options: PreprocessOptions,
177    output: String,
178    origins: Vec<ExpansionOrigin>,
179    dependencies: Vec<SourceId>,
180    absolute_include: bool,
181    visited: BTreeSet<SourceId>,
182    active: BTreeSet<SourceId>,
183    input_bytes: usize,
184    define_bytes: usize,
185    boundary: Boundary,
186    literal_if: bool,
187    relax_undefined_if: bool,
188}
189
190/// Incremental declaration boundary check, independent of expansion segments.
191/// The syntax parser still validates the actual declarations after expansion.
192#[derive(Default)]
193#[expect(
194    clippy::struct_excessive_bools,
195    reason = "incremental lexical boundary flags span independent expansion chunks"
196)]
197struct Boundary {
198    depth: usize,
199    quote: Option<u8>,
200    escaped: bool,
201    slash: bool,
202    block_comment: bool,
203    star: bool,
204    line_comment: bool,
205    pending: bool,
206    invalid: bool,
207}
208
209impl Boundary {
210    fn ready(&self) -> bool {
211        self.depth == 0
212            && self.quote.is_none()
213            && !self.block_comment
214            && !self.slash
215            && !self.pending
216            && !self.invalid
217    }
218
219    fn observe(&mut self, text: &str) {
220        for byte in text.bytes() {
221            if self.line_comment {
222                if byte == b'\n' {
223                    self.line_comment = false;
224                }
225                continue;
226            }
227            if self.block_comment {
228                if self.star && byte == b'/' {
229                    self.block_comment = false;
230                    self.star = false;
231                } else {
232                    self.star = byte == b'*';
233                }
234                continue;
235            }
236            if let Some(quote) = self.quote {
237                if self.escaped {
238                    self.escaped = false;
239                } else if byte == b'\\' {
240                    self.escaped = true;
241                } else if byte == quote {
242                    self.quote = None;
243                }
244                continue;
245            }
246            if self.slash {
247                self.slash = false;
248                match byte {
249                    b'/' => {
250                        self.line_comment = true;
251                        continue;
252                    }
253                    b'*' => {
254                        self.block_comment = true;
255                        continue;
256                    }
257                    _ => self.pending = true,
258                }
259            }
260            match byte {
261                b'/' => self.slash = true,
262                b'#' => self.line_comment = true,
263                b'\'' | b'"' => {
264                    self.quote = Some(byte);
265                    self.pending = true;
266                }
267                b'{' => {
268                    self.depth += 1;
269                    self.pending = true;
270                }
271                b'}' => {
272                    if self.depth == 0 {
273                        self.invalid = true;
274                    } else {
275                        self.depth -= 1;
276                        self.pending = self.depth != 0;
277                    }
278                }
279                b';' if self.depth == 0 => self.pending = false,
280                b if b.is_ascii_whitespace() => {}
281                _ => self.pending = true,
282            }
283        }
284    }
285}
286
287struct Conditional {
288    parent: bool,
289    condition: bool,
290    has_else: bool,
291    opening: Range<usize>,
292}
293
294fn span(buffer: &SourceBuffer, range: Range<usize>) -> SourceSpan {
295    SourceSpan::new(buffer.id().clone(), range.start as u64, range.end as u64)
296        .expect("preprocessor ranges are ordered")
297}
298
299fn error(buffer: &SourceBuffer, range: Range<usize>, message: impl Into<String>) -> Error {
300    Error::new(&codes::PARSE_GLM_PREPROCESSING, message).with_span(span(buffer, range))
301}
302
303fn limit(buffer: &SourceBuffer, range: Range<usize>) -> Error {
304    Error::new(
305        &codes::PARSE_GLM_LIMIT_EXCEEDED,
306        "GLM preprocessing resource limit exceeded",
307    )
308    .with_span(span(buffer, range))
309}
310
311fn valid_name(name: &str) -> bool {
312    !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
313}
314
315impl Context<'_> {
316    fn check_defines(&self, buffer: &SourceBuffer, range: Range<usize>) -> Result<(), Error> {
317        if self.options.defines.len() > MAX_DEFINES
318            || self.options.defines.values().any(|v| v.len() > MAX_LINE)
319            || self.define_bytes > MAX_BYTES
320        {
321            return Err(limit(buffer, range));
322        }
323        if self.options.defines.keys().any(|k| !valid_name(k)) {
324            return Err(error(
325                buffer,
326                range,
327                "GLM defines require alphanumeric/underscore names",
328            ));
329        }
330        if self
331            .options
332            .defines
333            .values()
334            .any(|v| v.contains(['\r', '\n', '\0']))
335        {
336            return Err(error(
337                buffer,
338                range,
339                "GLM explicit variables must be single-line non-NUL text",
340            ));
341        }
342        Ok(())
343    }
344
345    fn file(&mut self, buffer: &SourceBuffer, depth: usize) -> Result<(), Error> {
346        let whole = 0..buffer.bytes().len();
347        if depth == MAX_DEPTH || self.active.contains(buffer.id()) {
348            return Err(error(buffer, whole, "GLM include cycle or depth limit"));
349        }
350        if self.visited.contains(buffer.id()) && !self.options.reinclude {
351            return Err(error(
352                buffer,
353                whole,
354                "GLM file included more than once without reinclude",
355            ));
356        }
357        self.input_bytes = self.input_bytes.saturating_add(buffer.bytes().len());
358        if self.input_bytes > MAX_BYTES {
359            return Err(limit(buffer, whole));
360        }
361        if self.visited.insert(buffer.id().clone()) {
362            if self.visited.len() > MAX_FILES || self.input_bytes > MAX_BYTES {
363                return Err(limit(buffer, whole));
364            }
365            self.dependencies.push(buffer.id().clone());
366        }
367        let text = std::str::from_utf8(buffer.bytes())
368            .map_err(|_| error(buffer, whole.clone(), "GLM source is not UTF-8"))?;
369        if text.contains('\0') {
370            return Err(error(buffer, whole, "GLM source contains a NUL byte"));
371        }
372        self.active.insert(buffer.id().clone());
373        let mut stack = Vec::<Conditional>::new();
374        let mut enabled = true;
375        let mut offset = if buffer.has_utf8_bom() { 3 } else { 0 };
376        for line in text[offset..].split_inclusive('\n') {
377            let range = offset..offset + line.len();
378            if line.len() > MAX_LINE {
379                return Err(limit(buffer, range));
380            }
381            let trimmed = line.trim_start();
382            if let Some(directive) = trimmed.strip_prefix('#') {
383                // Comments on macro lines must follow whitespace in native GLM.
384                let directive = directive.split_once(" //").map_or(directive, |(a, _)| a);
385                let directive = directive
386                    .split_once("\t//")
387                    .map_or(directive, |(a, _)| a)
388                    .trim_end();
389                let (name, term) = directive
390                    .split_once(char::is_whitespace)
391                    .unwrap_or((directive, ""));
392                self.directive(
393                    buffer,
394                    range.clone(),
395                    name,
396                    term.trim_start(),
397                    &mut stack,
398                    &mut enabled,
399                    depth,
400                )?;
401            } else if enabled {
402                self.expand_to_output(buffer, range.clone())?;
403            }
404            offset = range.end;
405        }
406        if let Some(top) = stack.last() {
407            return Err(error(
408                buffer,
409                top.opening.clone(),
410                "GLM conditional has no #endif in its file",
411            ));
412        }
413        self.active.remove(buffer.id());
414        Ok(())
415    }
416
417    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
418    fn directive(
419        &mut self,
420        buffer: &SourceBuffer,
421        range: Range<usize>,
422        name: &str,
423        term: &str,
424        stack: &mut Vec<Conditional>,
425        enabled: &mut bool,
426        depth: usize,
427    ) -> Result<(), Error> {
428        match name {
429            "ifdef" | "ifndef" | "if" => {
430                if stack.len() == MAX_DEPTH {
431                    return Err(limit(buffer, range));
432                }
433                let condition = if *enabled {
434                    let term = self.expand_value(buffer, range.clone(), term)?;
435                    if name == "if" {
436                        self.condition(buffer, range.clone(), &term)?
437                    } else {
438                        if !valid_name(&term) {
439                            return Err(error(buffer, range, "invalid GLM conditional variable"));
440                        }
441                        self.options.defines.contains_key(&term) == (name == "ifdef")
442                    }
443                } else {
444                    false
445                };
446                stack.push(Conditional {
447                    parent: *enabled,
448                    condition,
449                    has_else: false,
450                    opening: range,
451                });
452                *enabled &= condition;
453            }
454            "else" => {
455                let top = stack
456                    .last_mut()
457                    .ok_or_else(|| error(buffer, range.clone(), "GLM #else without #if"))?;
458                if top.has_else || !term.is_empty() {
459                    return Err(error(buffer, range, "duplicate or malformed GLM #else"));
460                }
461                top.has_else = true;
462                *enabled = top.parent && !top.condition;
463            }
464            "endif" => {
465                let top = stack
466                    .pop()
467                    .ok_or_else(|| error(buffer, range.clone(), "GLM #endif without #if"))?;
468                if !term.is_empty() {
469                    return Err(error(buffer, range, "GLM #endif has unexpected text"));
470                }
471                *enabled = top.parent;
472            }
473            _ if !*enabled => {}
474            "define" => {
475                let value = self.expand_value(buffer, range.clone(), term)?;
476                let (key, value) = value.split_once('=').unwrap_or((&value, ""));
477                if !valid_name(key) {
478                    return Err(error(
479                        buffer,
480                        range,
481                        "expected #define NAME=value or #define NAME",
482                    ));
483                }
484                if let Some(old) = self.options.defines.get(key) {
485                    self.define_bytes -= old.len();
486                } else {
487                    self.define_bytes += key.len();
488                }
489                self.define_bytes += value.len();
490                if self.define_bytes > MAX_BYTES
491                    || (self.options.defines.len() == MAX_DEFINES
492                        && !self.options.defines.contains_key(key))
493                {
494                    return Err(limit(buffer, range));
495                }
496                self.options
497                    .defines
498                    .insert(key.to_owned(), value.to_owned());
499            }
500            "include" => {
501                if !self.boundary.ready() {
502                    return Err(error(
503                        buffer,
504                        range,
505                        "GLM includes require a complete top-level declaration boundary",
506                    ));
507                }
508                let value = self.expand_value(buffer, range.clone(), term)?;
509                let value = value.strip_suffix(';').unwrap_or(&value).trim_end();
510                let path = value
511                    .strip_prefix('"')
512                    .and_then(|s| s.strip_suffix('"'))
513                    .filter(|s| !s.is_empty() && !s.contains(['"', '\\', '\n', '\r', ':']));
514                let path = path.ok_or_else(|| {
515                    error(
516                        buffer,
517                        range.clone(),
518                        "only local quoted GLM includes are supported",
519                    )
520                })?;
521                let included = self
522                    .source
523                    .referenced_buffer_with_limit(
524                        &self.include_base,
525                        path,
526                        (MAX_BYTES - self.input_bytes) as u64,
527                    )
528                    .map_err(|e| {
529                        if e.diagnostics().first().is_some_and(|d| {
530                            d.code() == powerio_core::codes::READ_IO_BUFFER_BUDGET.code
531                        }) {
532                            limit(buffer, range.clone()).with_cause(e)
533                        } else {
534                            e.with_span(span(buffer, range.clone()))
535                        }
536                    })?;
537                self.absolute_include |= crate::portable_path::is_portable_absolute(path);
538                self.file(&included, depth + 1)?;
539                if !self.boundary.ready() {
540                    return Err(error(
541                        buffer,
542                        range,
543                        "included GLM file ends inside a declaration",
544                    ));
545                }
546                self.append("\n", span(buffer, range), false)?;
547            }
548            "set" | "warning" => {
549                // These remain syntax, not execution. Their later interpretation
550                // must decide static readiness and typed global conversions.
551                if name == "set" {
552                    let term = self.expand_value(buffer, range.clone(), term)?;
553                    if let Some((key, value)) = term.split_once('=')
554                        && (key == "reinclude"
555                            || (self.options.dialect == GlmDialect::Arras4_3
556                                && matches!(key, "literal_if" | "relax_undefined_if")))
557                    {
558                        let value = match value {
559                            "TRUE" | "true" | "1" => true,
560                            "FALSE" | "false" | "0" => false,
561                            _ => {
562                                return Err(error(
563                                    buffer,
564                                    range,
565                                    "GLM preprocessing switch requires an explicit boolean",
566                                ));
567                            }
568                        };
569                        match key {
570                            "reinclude" => self.options.reinclude = value,
571                            "literal_if" => self.literal_if = value,
572                            _ => self.relax_undefined_if = value,
573                        }
574                    }
575                }
576                self.expand_to_output(buffer, range)?;
577            }
578            _ => {
579                return Err(error(
580                    buffer,
581                    range,
582                    format!("unsupported active GLM directive #{name}"),
583                ));
584            }
585        }
586        Ok(())
587    }
588
589    fn condition(
590        &self,
591        buffer: &SourceBuffer,
592        range: Range<usize>,
593        term: &str,
594    ) -> Result<bool, Error> {
595        if self.options.dialect == GlmDialect::Arras4_3 {
596            return self.arras_condition(buffer, range, term);
597        }
598        let at = term
599            .find(['!', '<', '>', '='])
600            .ok_or_else(|| error(buffer, range.clone(), "GLM #if requires NAME<op>value"))?;
601        let (key, rest) = term.split_at(at);
602        let op_end = rest
603            .find(|c| !matches!(c, '!' | '<' | '>' | '='))
604            .unwrap_or(rest.len());
605        let (op, rhs) = rest.split_at(op_end);
606        let lhs = self.options.defines.get(key).ok_or_else(|| {
607            error(
608                buffer,
609                range.clone(),
610                "GLM #if variable is not explicitly defined",
611            )
612        })?;
613        match op {
614            "<" => Ok(lhs.as_str() < rhs),
615            ">" => Ok(lhs.as_str() > rhs),
616            "<=" => Ok(lhs.as_str() <= rhs),
617            ">=" => Ok(lhs.as_str() >= rhs),
618            "==" => Ok(lhs == rhs),
619            "!=" => Ok(lhs != rhs),
620            _ => Err(error(buffer, range, "invalid GLM #if comparison operator")),
621        }
622    }
623
624    fn arras_condition(
625        &self,
626        buffer: &SourceBuffer,
627        range: Range<usize>,
628        term: &str,
629    ) -> Result<bool, Error> {
630        let (left, rest) = if let Some(quoted) = term.strip_prefix('"') {
631            quoted.split_once('"').ok_or_else(|| {
632                error(buffer, range.clone(), "unterminated Arras #if left operand")
633            })?
634        } else {
635            let end = term
636                .find(|c: char| c.is_ascii_whitespace() || matches!(c, '!' | '<' | '>' | '='))
637                .ok_or_else(|| error(buffer, range.clone(), "missing Arras #if comparison"))?;
638            term.split_at(end)
639        };
640        let rest = rest.trim_start();
641        let op = ["not_in", "in", "<=", ">=", "==", "!=", "<", ">"]
642            .into_iter()
643            .find(|op| rest.starts_with(op))
644            .ok_or_else(|| error(buffer, range.clone(), "invalid Arras #if comparison"))?;
645        let rhs = rest[op.len()..].trim_start();
646        let rhs = if let Some(quoted) = rhs.strip_prefix('"') {
647            quoted
648                .strip_suffix('"')
649                .filter(|s| !s.contains('"'))
650                .ok_or_else(|| {
651                    error(
652                        buffer,
653                        range.clone(),
654                        "unterminated Arras #if right operand",
655                    )
656                })?
657        } else {
658            if rhs.chars().any(char::is_whitespace) {
659                return Err(error(
660                    buffer,
661                    range,
662                    "Arras #if operands containing spaces must be quoted",
663                ));
664            }
665            rhs
666        };
667        let lhs = if self.literal_if {
668            left
669        } else {
670            match self.options.defines.get(left) {
671                Some(value) => value,
672                None if self.relax_undefined_if => "",
673                None => {
674                    return Err(error(
675                        buffer,
676                        range,
677                        "Arras #if variable is not explicitly defined",
678                    ));
679                }
680            }
681        };
682        match op {
683            "<" => Ok(lhs < rhs),
684            ">" => Ok(lhs > rhs),
685            "<=" => Ok(lhs <= rhs),
686            ">=" => Ok(lhs >= rhs),
687            "==" => Ok(lhs == rhs),
688            "!=" => Ok(lhs != rhs),
689            "in" => Ok(lhs.contains(rhs)),
690            "not_in" => Ok(!lhs.contains(rhs)),
691            _ => unreachable!("operator was selected from a fixed set"),
692        }
693    }
694
695    fn expand_value(
696        &self,
697        buffer: &SourceBuffer,
698        range: Range<usize>,
699        text: &str,
700    ) -> Result<String, Error> {
701        let mut output = String::new();
702        for (literal, variable) in
703            substitutions(text).map_err(|m| error(buffer, range.clone(), m))?
704        {
705            let value = if let Some(key) = variable {
706                self.options.defines.get(key).ok_or_else(|| {
707                    error(
708                        buffer,
709                        range.clone(),
710                        format!("GLM variable {key} is not explicitly defined"),
711                    )
712                })?
713            } else {
714                literal
715            };
716            if output.len().saturating_add(value.len()) > MAX_LINE {
717                return Err(limit(buffer, range));
718            }
719            output.push_str(value);
720        }
721        Ok(output)
722    }
723
724    fn expand_to_output(
725        &mut self,
726        buffer: &SourceBuffer,
727        range: Range<usize>,
728    ) -> Result<(), Error> {
729        let text = std::str::from_utf8(&buffer.bytes()[range.clone()])
730            .expect("file validates UTF-8 before splitting lines");
731        let comment = if text.trim_start().starts_with('#') {
732            text.find(" //").into_iter().chain(text.find("\t//")).min()
733        } else {
734            text.find("//")
735        };
736        let body = comment.map_or(text, |end| &text[..end]);
737        let initial_size = self.output.len();
738        let mut offset = range.start;
739        for (literal, variable) in
740            substitutions(body).map_err(|m| error(buffer, range.clone(), m))?
741        {
742            let original = span(buffer, offset..offset + literal.len());
743            if let Some(key) = variable {
744                let value = self
745                    .options
746                    .defines
747                    .get(key)
748                    .ok_or_else(|| {
749                        error(
750                            buffer,
751                            offset..offset + literal.len(),
752                            format!("GLM variable {key} is not explicitly defined"),
753                        )
754                    })?
755                    .clone();
756                self.append(&value, original, false)?;
757            } else {
758                self.append(literal, original, true)?;
759            }
760            offset += literal.len();
761            if self.output.len() - initial_size > MAX_LINE {
762                return Err(limit(buffer, range));
763            }
764        }
765        if comment.is_some() && text.ends_with('\n') {
766            self.append("\n", span(buffer, range.end - 1..range.end), true)?;
767        }
768        if !text.trim_start().starts_with('#')
769            && self.output[initial_size..].trim_start().starts_with('#')
770        {
771            return Err(error(
772                buffer,
773                range,
774                "GLM variable expansion generated a directive; explicit directive syntax is required",
775            ));
776        }
777        Ok(())
778    }
779
780    fn append(&mut self, text: &str, original: SourceSpan, exact: bool) -> Result<(), Error> {
781        if text.is_empty() {
782            return Ok(());
783        }
784        if self.output.len().saturating_add(text.len()) > MAX_BYTES
785            || self.origins.len() == MAX_ORIGINS
786        {
787            return Err(Error::new(
788                &codes::PARSE_GLM_LIMIT_EXCEEDED,
789                "GLM expansion exceeds its byte or source-map budget",
790            )
791            .with_span(original));
792        }
793        let start = self.output.len();
794        self.output.push_str(text);
795        self.boundary.observe(text);
796        self.origins.push(ExpansionOrigin {
797            expanded: start..self.output.len(),
798            original,
799            exact,
800        });
801        Ok(())
802    }
803}
804
805/// Segments retain their original lengths, including the complete reference.
806fn substitutions(mut text: &str) -> Result<Vec<(&str, Option<&str>)>, &'static str> {
807    let mut result = Vec::new();
808    while let Some(start) = text.find("${") {
809        result.push((&text[..start], None));
810        text = &text[start..];
811        let end = text
812            .find('}')
813            .ok_or("unterminated GLM variable reference")?;
814        let key = &text[2..end];
815        if !valid_name(key) {
816            return Err("GLM variable reference requires a simple explicit name");
817        }
818        result.push((&text[..=end], Some(key)));
819        text = &text[end + 1..];
820    }
821    result.push((text, None));
822    Ok(result)
823}