Skip to main content

workshop_rs/frontend/
parser.rs

1pub(crate) use std::collections::HashMap;
2use std::sync::OnceLock;
3
4pub(crate) use crate::core::signatures::ExpectedDomain;
5pub(crate) use crate::core::source::{Position, SourceFile, Span};
6use crate::program::Program;
7pub(crate) use crate::settings::table::KeyKind;
8pub(crate) use crate::settings::{PathPart, table};
9pub(crate) use crate::settings::{Settings, SettingsListElement, SettingsNode};
10pub(crate) use crate::wir::{
11    self, Action, Event, EventTarget, EventTeam, ModifyOp, PlayerEventKind, Value, ValueNode,
12};
13
14use super::keywords::{canonical_structural_id, spelling_token_len, structural_spellings};
15pub(crate) use super::lexer::{Token, TokenKind, tokenize};
16pub(crate) use crate::catalog::{Catalog, Kind, Locale};
17pub(crate) use crate::core::error::{Result, WorkshopError};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub(crate) enum Stop {
21    SectionClosed,
22    End,
23    ElseIf,
24    Else,
25}
26
27pub(crate) enum AssignmentOperator {
28    Set,
29    Modify(ModifyOp),
30}
31
32/// Parse localized Workshop text into Workshop IR using the catalog's
33/// canonical call-signature context. Ambiguous bare enum members resolve when
34/// their enclosing call pins one matching domain; unpinned ambiguity remains a
35/// structured canonical value. See [`parse_with_context`] when a
36/// consumer needs to provide additional signature context.
37pub fn parse(input: &str, catalog: &Catalog, locale: &Locale) -> Result<Program> {
38    parse_with_context(input, catalog, locale, catalog)
39}
40
41#[cfg(test)]
42pub(crate) fn parse_wir(input: &str, catalog: &Catalog, locale: &Locale) -> Result<wir::Program> {
43    parse_wir_with_context(input, catalog, locale, catalog)
44}
45
46/// Compatibility entry point for crate-internal storage tests and migration
47/// checks. Ordinary consumers should use [`parse`], which returns [`Program`].
48/// Parse localized Workshop text into Workshop IR, resolving ambiguous bare
49/// enum members from the enclosing call's canonical signature context (#111).
50///
51/// When a bare member spelling matches several enum domains, the parser asks
52/// [`ExpectedDomain::expected_domain`] for the domain the enclosing call's
53/// signature expects at that argument position; the member resolves only when
54/// that expected domain is one of the matching domains (i.e. the signature
55/// pins exactly one). Without a pin all matching candidates are preserved.
56pub fn parse_with_context(
57    input: &str,
58    catalog: &Catalog,
59    locale: &Locale,
60    context: &dyn ExpectedDomain,
61) -> Result<Program> {
62    parse_wir_with_context(input, catalog, locale, context).and_then(Program::from_wir)
63}
64
65pub(crate) fn parse_wir_with_context(
66    input: &str,
67    catalog: &Catalog,
68    locale: &Locale,
69    context: &dyn ExpectedDomain,
70) -> Result<wir::Program> {
71    let tokens = tokenize(input).map_err(|error| {
72        WorkshopError::malformed(error.message, Some(synthetic_span(error.position)))
73    })?;
74    ParseContext {
75        tokens,
76        pos: 0,
77        source: input,
78        catalog,
79        locale: locale.clone(),
80        context,
81        expected_domain: None,
82        condition_pending: false,
83        in_condition: false,
84        call_stack: Vec::new(),
85        target: wir::Program::default(),
86        globals: HashMap::new(),
87        players: HashMap::new(),
88        subroutines: HashMap::new(),
89        next_global_index: 0,
90        next_player_index: 0,
91        structural_spellings: HashMap::new(),
92    }
93    .program()
94}
95
96pub(crate) fn synthetic_span(position: Position) -> Span {
97    Span::new(crate::core::ids::Id::from_index(0), position, position)
98}
99
100pub(crate) struct ParseContext<'a> {
101    pub(crate) tokens: Vec<Token>,
102    pub(crate) pos: usize,
103    pub(crate) source: &'a str,
104    pub(crate) catalog: &'a Catalog,
105    pub(crate) locale: Locale,
106    pub(crate) context: &'a dyn ExpectedDomain,
107    pub(crate) expected_domain: Option<&'a str>,
108    pub(crate) condition_pending: bool,
109    pub(crate) in_condition: bool,
110    pub(crate) call_stack: Vec<String>,
111    pub(crate) target: wir::Program,
112    pub(crate) globals: HashMap<String, wir::GlobalVarId>,
113    pub(crate) players: HashMap<String, wir::PlayerVarId>,
114    pub(crate) subroutines: HashMap<String, wir::SubroutineId>,
115    pub(crate) next_global_index: u32,
116    pub(crate) next_player_index: u32,
117    structural_spellings: HashMap<String, Vec<String>>,
118}
119
120impl<'a> ParseContext<'a> {
121    pub(crate) fn with_domain<T>(
122        &mut self,
123        domain: Option<&'a str>,
124        f: impl FnOnce(&mut Self) -> Result<T>,
125    ) -> Result<T> {
126        let saved = self.expected_domain;
127        self.expected_domain = domain;
128        let result = f(self);
129        self.expected_domain = saved;
130        result
131    }
132
133    pub(crate) fn value_in_domain(&mut self, domain: Option<&'a str>) -> Result<wir::ValueId> {
134        self.with_domain(domain, |p| p.value())
135    }
136
137    pub(crate) fn resolve_entry(
138        &self,
139        kind: Kind,
140        spelling: &str,
141    ) -> Option<&'a crate::catalog::CatalogEntry> {
142        self.catalog
143            .resolve(kind, &self.locale, spelling)
144            .or_else(|| {
145                if self.locale != *self.catalog.primary_locale() {
146                    self.catalog
147                        .resolve(kind, self.catalog.primary_locale(), spelling)
148                } else {
149                    None
150                }
151            })
152    }
153
154    pub(crate) fn canonical_keyword(&self, spelling: &str) -> String {
155        canonical_structural_id(self.catalog, &self.locale, spelling)
156    }
157
158    pub(crate) fn line_has_assignment(&self) -> bool {
159        let tokens: Vec<_> = self.tokens[self.pos..]
160            .iter()
161            .take_while(|token| !matches!(token.kind, TokenKind::Semi | TokenKind::RBrace))
162            .collect();
163        tokens.iter().any(|token| {
164            matches!(&token.kind, TokenKind::Op(op) if matches!(op.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%="))
165        }) || tokens.windows(2).any(|window| {
166            matches!(&window[0].kind, TokenKind::Word(_))
167                && matches!(&window[1].kind, TokenKind::Op(op) if op == "=")
168        })
169    }
170
171    pub(crate) fn phrase(&mut self) -> Result<(String, Position, Position)> {
172        let mut parts: Vec<(String, Position, Position)> = Vec::new();
173        let (start, mut end) = match self.peek() {
174            Some(Token {
175                kind: TokenKind::Word(word),
176                start,
177                end,
178            }) => {
179                parts.push((word.clone(), start, end));
180                (start, end)
181            }
182            Some(Token {
183                kind: TokenKind::Number { text, .. },
184                start,
185                end,
186            }) => {
187                parts.push((text.clone(), start, end));
188                (start, end)
189            }
190            Some(token) => return Err(self.malformed("expected an identifier", &token)),
191            None => return Err(self.malformed("expected an identifier", self.eof())),
192        };
193        self.pos += 1;
194        while let Some(token) = self.peek() {
195            match token {
196                Token {
197                    kind: TokenKind::Word(word),
198                    start: word_start,
199                    end: word_end,
200                } => {
201                    if matches!(
202                        self.peek_kind_at(1),
203                        Some(TokenKind::Op(equal)) if equal == "="
204                    ) {
205                        break;
206                    }
207                    parts.push((word.clone(), word_start, word_end));
208                    end = word_end;
209                    self.pos += 1;
210                }
211                Token {
212                    kind: TokenKind::Number { text, .. },
213                    start: number_start,
214                    end: number_end,
215                } => {
216                    parts.push((text.clone(), number_start, number_end));
217                    end = number_end;
218                    self.pos += 1;
219                }
220                _ => break,
221            }
222        }
223        Ok((join_as_written(&parts), start, end))
224    }
225
226    pub(crate) fn phrase_on_line(&mut self) -> Result<(String, Position, Position)> {
227        self.phrase_on_line_impl(false)
228    }
229
230    pub(crate) fn settings_list_name(&mut self) -> Result<(String, Position, Position)> {
231        self.phrase_on_line_impl(true)
232    }
233
234    fn phrase_on_line_impl(&mut self, list_name: bool) -> Result<(String, Position, Position)> {
235        let mut parts: Vec<(String, Position, Position)> = Vec::new();
236        let (start, mut end, line) = match self.peek() {
237            Some(Token {
238                kind: TokenKind::Word(word),
239                start,
240                end,
241            }) => {
242                parts.push((word.clone(), start, end));
243                (start, end, start.line)
244            }
245            Some(Token {
246                kind: TokenKind::Number { text, .. },
247                start,
248                end,
249            }) => {
250                parts.push((text.clone(), start, end));
251                (start, end, start.line)
252            }
253            Some(token) => return Err(self.malformed("expected an identifier", &token)),
254            None => return Err(self.malformed("expected an identifier", self.eof())),
255        };
256        self.pos += 1;
257        while let Some(token) = self.peek() {
258            let (text, word_start, word_end) = match token {
259                Token {
260                    kind: TokenKind::Word(word),
261                    start,
262                    end,
263                } => (word.clone(), start, end),
264                Token {
265                    kind: TokenKind::Number { text, .. },
266                    start,
267                    end,
268                } => (text.clone(), start, end),
269                Token {
270                    kind: TokenKind::Dot,
271                    start,
272                    end,
273                } => (".".to_string(), start, end),
274                Token {
275                    kind: TokenKind::Op(op),
276                    start,
277                    end,
278                } if matches!(op.as_str(), "-" | "%") => (op.clone(), start, end),
279                Token {
280                    kind: TokenKind::Colon,
281                    start,
282                    end,
283                } if list_name => (":".to_string(), start, end),
284                _ => break,
285            };
286            if word_start.line != line {
287                break;
288            }
289            parts.push((text, word_start, word_end));
290            end = word_end;
291            self.pos += 1;
292        }
293        Ok((join_as_written(&parts), start, end))
294    }
295
296    pub(crate) fn phrase_on_line_with_colon(&mut self) -> Result<(String, Position, Position)> {
297        let (mut phrase, start, mut end) = self.phrase_on_line()?;
298        if matches!(
299            self.peek(),
300            Some(Token {
301                kind: TokenKind::Colon,
302                ..
303            })
304        ) {
305            let colon_pos = self.pos;
306            self.next();
307            let (rest, _, rest_end) = self.phrase_on_line()?;
308            if matches!(
309                self.peek(),
310                Some(Token {
311                    kind: TokenKind::LBrace,
312                    ..
313                })
314            ) {
315                phrase.push(':');
316                phrase.push(' ');
317                phrase.push_str(&rest);
318                end = rest_end;
319            } else {
320                self.pos = colon_pos;
321            }
322        }
323        Ok((phrase, start, end))
324    }
325
326    pub(crate) fn enum_member_phrase(&mut self) -> Result<(String, Position, Position)> {
327        let first = self
328            .peek()
329            .ok_or_else(|| self.malformed("expected an enum member", self.eof()))?;
330        let start = first.start;
331        let line = first.start.line;
332        let mut end = first.end;
333        let mut parts: Vec<(String, Position, Position)> = Vec::new();
334        while let Some(token) = self.peek() {
335            if token.start.line != line
336                || matches!(
337                    token.kind,
338                    TokenKind::RParen | TokenKind::Comma | TokenKind::LParen
339                )
340            {
341                break;
342            }
343            self.pos += 1;
344            end = token.end;
345            parts.push((raw_token_text(&token.kind), token.start, token.end));
346        }
347        if parts.is_empty() {
348            return Err(self.malformed("expected an enum member", &first));
349        }
350        Ok((join_as_written(&parts), start, end))
351    }
352
353    pub(crate) fn line_text(&mut self) -> Result<(String, Option<Span>)> {
354        let mut parts: Vec<(String, Position, Position)> = Vec::new();
355        let mut start = None;
356        let mut end = None;
357        loop {
358            match self.peek() {
359                Some(Token {
360                    kind: TokenKind::Semi,
361                    ..
362                }) => {
363                    self.pos += 1;
364                    break;
365                }
366                Some(token) => {
367                    start.get_or_insert(token.start);
368                    end = Some(token.end);
369                    match &token.kind {
370                        TokenKind::Word(_)
371                        | TokenKind::Number { .. }
372                        | TokenKind::Dot
373                        | TokenKind::Colon
374                        | TokenKind::Op(_) => {}
375                        _ => return Err(self.malformed("expected a text line", &token)),
376                    }
377                    parts.push((raw_token_text(&token.kind), token.start, token.end));
378                    self.pos += 1;
379                }
380                None => {
381                    return Err(self.malformed("unexpected end of input in line", self.eof()));
382                }
383            }
384        }
385        Ok((
386            join_as_written(&parts),
387            start
388                .zip(end)
389                .map(|(start, end)| Span::new(self.file(), start, end)),
390        ))
391    }
392
393    pub(crate) fn spelling_run(
394        &self,
395        pos: usize,
396        attaches_to: Option<Position>,
397    ) -> Vec<(usize, String, Position)> {
398        let mut ends = Vec::new();
399        let mut text = String::new();
400        let mut index = pos;
401        let mut prev_end = attaches_to;
402        while let Some(token) = self.tokens.get(index) {
403            let fragment = match &token.kind {
404                TokenKind::Word(word) => word.as_str(),
405                TokenKind::Number { text, .. } => text.as_str(),
406                TokenKind::Dot => ".",
407                TokenKind::Colon => ":",
408                TokenKind::Op(op) if op == "not" => "!",
409                TokenKind::Op(op) if op == "-" => "-",
410                _ => break,
411            };
412            // `-` is allowed spaced (the client emits `X - Y` member
413            // spellings); `.`, `:`, `!` only continue a spelling when glued
414            // to the previous token.
415            let needs_adjacent = matches!(token.kind, TokenKind::Dot | TokenKind::Colon)
416                || matches!(&token.kind, TokenKind::Op(op) if op == "not");
417            if needs_adjacent && prev_end.is_none_or(|end| token.start != end) {
418                break;
419            }
420            if prev_end.is_some_and(|end| token.start != end) {
421                text.push(' ');
422            }
423            text.push_str(fragment);
424            prev_end = Some(token.end);
425            index += 1;
426            ends.push((text.len(), token.end));
427        }
428        ends.into_iter()
429            .enumerate()
430            .rev()
431            .map(|(i, (len, end))| (i + 1, text[..len].to_string(), end))
432            .collect()
433    }
434
435    pub(crate) fn catalog_spelling(
436        &mut self,
437        accept: impl Fn(&str) -> bool,
438    ) -> Option<(String, Position, Position)> {
439        let start = self.tokens.get(self.pos)?.start;
440        for (count, text, end) in self.spelling_run(self.pos, None) {
441            if count < 2 {
442                break;
443            }
444            if accept(&text) {
445                self.pos += count;
446                return Some((text, start, end));
447            }
448        }
449        None
450    }
451
452    pub(crate) fn extend_catalog_spelling(
453        &mut self,
454        phrase: &str,
455        phrase_end: Position,
456        accept: impl Fn(&str) -> bool,
457    ) -> Option<(String, Position)> {
458        for (count, tail, end) in self.spelling_run(self.pos, Some(phrase_end)) {
459            let joined = format!("{phrase}{tail}");
460            if accept(&joined) {
461                self.pos += count;
462                return Some((joined, end));
463            }
464        }
465        None
466    }
467
468    pub(crate) fn extend_entry_spelling(
469        &mut self,
470        phrase: &str,
471        phrase_end: Position,
472        kinds: &[Kind],
473    ) -> Option<(String, Position)> {
474        let catalog = self.catalog;
475        let locale = self.locale.clone();
476        let primary = catalog.primary_locale();
477        self.extend_catalog_spelling(phrase, phrase_end, |spelling| {
478            kinds.iter().any(|kind| {
479                catalog.resolve(*kind, &locale, spelling).is_some()
480                    || (locale != *primary && catalog.resolve(*kind, primary, spelling).is_some())
481            })
482        })
483    }
484
485    pub(crate) fn consume_phrase(&mut self, expected: &str) -> Result<()> {
486        let (phrase, _, _) = self.phrase()?;
487        if phrase != expected {
488            return Err(self.malformed(&format!("expected '{expected}'"), self.previous()));
489        }
490        Ok(())
491    }
492
493    fn compute_structural_spellings(&self, id: &str) -> Vec<String> {
494        structural_spellings(self.catalog, &self.locale, id)
495    }
496
497    fn match_spelling_tokens(&self, spelling: &str) -> Option<usize> {
498        spelling_token_len(&self.tokens, self.pos, spelling)
499    }
500
501    pub(crate) fn keyword_prefix(&mut self, ids: &[&str]) -> Option<String> {
502        let mut best: Option<(String, usize)> = None;
503        for id in ids {
504            if !self.structural_spellings.contains_key(*id) {
505                let spellings = self.compute_structural_spellings(id);
506                self.structural_spellings
507                    .insert((*id).to_string(), spellings);
508            }
509            for spelling in &self.structural_spellings[*id] {
510                if let Some(len) = self.match_spelling_tokens(spelling)
511                    && best.as_ref().is_none_or(|(_, length)| len > *length)
512                {
513                    best = Some(((*id).to_string(), len));
514                }
515            }
516        }
517        if let Some((id, len)) = best {
518            self.pos += len;
519            Some(id)
520        } else {
521            None
522        }
523    }
524
525    pub(crate) fn consume_keyword(&mut self, id: &str) -> Result<()> {
526        if self.keyword_prefix(&[id]).is_some() {
527            return Ok(());
528        }
529        match self.peek() {
530            Some(token) => Err(self.malformed(&format!("expected '{id}'"), &token)),
531            None => Err(self.malformed(&format!("expected '{id}'"), self.eof())),
532        }
533    }
534
535    pub(crate) fn expect_keyword(&mut self, expected: &str) -> Result<Position> {
536        let start = self.peek().map(|token| token.start);
537        if self.keyword_prefix(&[expected]).is_some() {
538            return Ok(start.unwrap_or_else(|| self.previous().start));
539        }
540        match self.next() {
541            Some(token) => Err(self.malformed(&format!("expected '{expected}'"), &token)),
542            None => Err(self.malformed(&format!("expected '{expected}'"), self.eof())),
543        }
544    }
545
546    pub(crate) fn expect(&mut self, kind: TokenKind, message: &str) -> Result<()> {
547        match self.next() {
548            Some(token) if token.kind == kind => Ok(()),
549            Some(token) => Err(self.malformed(message, &token)),
550            None => Err(self.malformed(message, self.eof())),
551        }
552    }
553
554    pub(crate) fn expect_semi(&mut self, message: &str) -> Result<()> {
555        match self.peek() {
556            Some(Token {
557                kind: TokenKind::Semi,
558                ..
559            }) => {
560                self.pos += 1;
561                Ok(())
562            }
563            Some(token) if token.start.line > self.previous().end.line => {
564                let end = self.previous().end;
565                Err(WorkshopError::malformed(
566                    message.to_string(),
567                    Some(Span::new(self.file(), end, end)),
568                ))
569            }
570            Some(token) => Err(self.malformed(message, &token)),
571            None => Err(self.malformed(message, self.eof())),
572        }
573    }
574
575    pub(crate) fn expect_string(&mut self, message: &str) -> Result<String> {
576        match self.next() {
577            Some(Token {
578                kind: TokenKind::String(content),
579                ..
580            }) => Ok(content),
581            Some(token) => Err(self.malformed(message, &token)),
582            None => Err(self.malformed(message, self.eof())),
583        }
584    }
585
586    pub(crate) fn malformed(&self, message: &str, token: &Token) -> WorkshopError {
587        WorkshopError::malformed(
588            message.to_string(),
589            Some(Span::new(self.file(), token.start, token.end)),
590        )
591    }
592
593    pub(crate) fn unknown(&self, kind: &'static str, spelling: &str) -> WorkshopError {
594        WorkshopError::unknown(kind, spelling.to_string(), self.locale.clone(), None)
595    }
596
597    pub(crate) fn unknown_at(
598        &self,
599        kind: &'static str,
600        spelling: &str,
601        start: Position,
602        end: Position,
603    ) -> WorkshopError {
604        WorkshopError::unknown(
605            kind,
606            spelling.to_string(),
607            self.locale.clone(),
608            Some(Span::new(self.file(), start, end)),
609        )
610    }
611
612    pub(crate) fn accepted_spellings(
613        &self,
614        kind: Kind,
615        include_primary: bool,
616    ) -> impl Iterator<Item = &str> {
617        let catalog = self.catalog;
618        let primary = catalog.primary_locale();
619        let include_primary = include_primary && self.locale != *primary;
620        catalog.entries_of(kind).flat_map(move |entry| {
621            let local: &[String] = entry.spellings(&self.locale);
622            let fallback: &[String] = if include_primary {
623                entry.spellings(primary)
624            } else {
625                &[]
626            };
627            local.iter().chain(fallback).map(String::as_str)
628        })
629    }
630
631    pub(crate) fn accepted_member_spellings<'b>(
632        &'b self,
633        domain: &'b str,
634        include_primary: bool,
635    ) -> impl Iterator<Item = &'b str> {
636        let catalog = self.catalog;
637        let primary = catalog.primary_locale();
638        let include_primary = include_primary && self.locale != *primary;
639        catalog
640            .enum_domain(domain)
641            .into_iter()
642            .flat_map(move |domain| {
643                domain.members.iter().flat_map(move |member| {
644                    let local: &[String] = member.spellings(&self.locale);
645                    let fallback: &[String] = if include_primary {
646                        member.spellings(primary)
647                    } else {
648                        &[]
649                    };
650                    local.iter().chain(fallback).map(String::as_str)
651                })
652            })
653    }
654
655    pub(crate) fn spelling_candidates(
656        &self,
657        kind: Kind,
658        spelling: &str,
659        include_primary: bool,
660    ) -> Vec<String> {
661        crate::core::suggest::nearest(
662            spelling,
663            self.accepted_spellings(kind, include_primary),
664            crate::core::suggest::CANDIDATE_LIMIT,
665        )
666    }
667
668    pub(crate) fn member_candidates(
669        &self,
670        domain: &str,
671        spelling: &str,
672        include_primary: bool,
673    ) -> Vec<String> {
674        crate::core::suggest::nearest(
675            spelling,
676            self.accepted_member_spellings(domain, include_primary),
677            crate::core::suggest::CANDIDATE_LIMIT,
678        )
679    }
680
681    pub(crate) fn peek(&self) -> Option<Token> {
682        self.tokens.get(self.pos).cloned()
683    }
684
685    pub(crate) fn peek_at(&self, offset: usize) -> Option<Token> {
686        self.tokens.get(self.pos + offset).cloned()
687    }
688
689    pub(crate) fn peek_kind_at(&self, offset: usize) -> Option<&TokenKind> {
690        self.tokens.get(self.pos + offset).map(|token| &token.kind)
691    }
692
693    pub(crate) fn next(&mut self) -> Option<Token> {
694        let token = self.tokens.get(self.pos).cloned();
695        if token.is_some() {
696            self.pos += 1;
697        }
698        token
699    }
700
701    pub(crate) fn previous(&self) -> &Token {
702        self.tokens
703            .get(self.pos.saturating_sub(1))
704            .unwrap_or_else(|| self.tokens.last().unwrap())
705    }
706
707    pub(crate) fn previous_span(&self) -> (Position, Position) {
708        let token = self.previous();
709        (token.start, token.end)
710    }
711
712    pub(crate) fn span_here(&self) -> (Position, Position) {
713        let token = self
714            .peek()
715            .unwrap_or_else(|| self.tokens.last().unwrap().clone());
716        (token.start, token.end)
717    }
718
719    pub(crate) fn eof(&self) -> &Token {
720        self.tokens.last().unwrap()
721    }
722
723    pub(crate) fn file(&self) -> crate::core::ids::Id<SourceFile> {
724        crate::core::ids::Id::from_index(0)
725    }
726}
727
728pub(super) fn structural_keyword_map() -> &'static HashMap<String, String> {
729    static KEYWORDS: OnceLock<HashMap<String, String>> = OnceLock::new();
730    KEYWORDS.get_or_init(|| {
731        serde_json::from_str(include_str!("structural_keywords.json"))
732            .expect("structural keyword data is valid JSON")
733    })
734}
735
736pub(crate) fn canonical_keyword(keyword: &str) -> &str {
737    structural_keyword_map()
738        .get(keyword)
739        .map(String::as_str)
740        .unwrap_or(keyword)
741}
742
743pub(crate) fn join_as_written(parts: &[(String, Position, Position)]) -> String {
744    let mut text = String::new();
745    let mut prev_end: Option<Position> = None;
746    for (fragment, start, end) in parts {
747        if prev_end.is_some_and(|prev| prev != *start) {
748            text.push(' ');
749        }
750        text.push_str(fragment);
751        prev_end = Some(*end);
752    }
753    text
754}
755
756pub(crate) fn raw_token_text(kind: &TokenKind) -> String {
757    match kind {
758        TokenKind::Word(value) => value.clone(),
759        TokenKind::Number { text, .. } => text.clone(),
760        TokenKind::String(value) => format!("\"{}\"", crate::output::emitter::escape_string(value)),
761        TokenKind::Op(value) => match value.as_str() {
762            "not" => "!".to_string(),
763            "and" => "&&".to_string(),
764            "or" => "||".to_string(),
765            _ => value.clone(),
766        },
767        TokenKind::LParen => "(".to_string(),
768        TokenKind::RParen => ")".to_string(),
769        TokenKind::Comma => ",".to_string(),
770        TokenKind::Semi => ";".to_string(),
771        TokenKind::LBrace => "{".to_string(),
772        TokenKind::RBrace => "}".to_string(),
773        TokenKind::Colon => ":".to_string(),
774        TokenKind::Dot => ".".to_string(),
775        TokenKind::LBracket => "[".to_string(),
776        TokenKind::RBracket => "]".to_string(),
777        TokenKind::Eof => String::new(),
778    }
779}