Skip to main content

submilli_engine/
package_config.rs

1//! Package-local build parameters. Token substitution retains the original source
2//! spans, so diagnostics and authority maps refer to the author's config file.
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::compiler_error::CompileError;
8use crate::{Asi, Diagnostic, FileId, Severity, Span, Token, TokenKind};
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum ConfigValue {
13    /// Compiler literal text, including lossless lone-surrogate escapes.
14    String(#[serde(with = "string_value")] String),
15    Number(f64),
16    Boolean(bool),
17}
18
19impl PartialEq for ConfigValue {
20    fn eq(&self, other: &Self) -> bool {
21        match (self, other) {
22            (Self::String(left), Self::String(right)) => left == right,
23            (Self::Number(left), Self::Number(right)) => left.to_bits() == right.to_bits(),
24            (Self::Boolean(left), Self::Boolean(right)) => left == right,
25            _ => false,
26        }
27    }
28}
29
30// Bit equality is reflexive even for externally constructed non-finite values;
31// builds accept finite numbers only and preserve the sign of zero.
32impl Eq for ConfigValue {}
33
34impl ConfigValue {
35    pub fn type_name(&self) -> &'static str {
36        match self {
37            Self::String(_) => "string",
38            Self::Number(_) => "number",
39            Self::Boolean(_) => "boolean",
40        }
41    }
42
43    fn token(&self) -> TokenKind {
44        match self {
45            Self::String(value) => TokenKind::StringLiteral(value.clone()),
46            Self::Number(value) => TokenKind::NumberLiteral(*value),
47            Self::Boolean(value) => TokenKind::BooleanLiteral(*value),
48        }
49    }
50}
51
52#[derive(Clone, Debug)]
53pub struct ConfigParameter {
54    pub name: String,
55    pub type_name: &'static str,
56    pub default: Option<ConfigValue>,
57    pub span: Span,
58    header: Vec<Token>,
59    value_span: Span,
60}
61
62pub fn parameters(source: &str, file: FileId) -> Result<Vec<ConfigParameter>, CompileError> {
63    let mut asi = Asi::new(source, file);
64    let mut tokens = Vec::new();
65    loop {
66        let token = asi.next_token();
67        let eof = token.kind == TokenKind::Eof;
68        tokens.push(token);
69        if eof {
70            break;
71        }
72    }
73    let diagnostics = asi.finish()?;
74    if diagnostics
75        .iter()
76        .any(|diag| diag.severity == Severity::Error)
77    {
78        return Err(diagnostics.into());
79    }
80    let mut reader = ConfigReader {
81        source,
82        tokens: &tokens,
83        pos: 0,
84    };
85    let mut result = Vec::new();
86    let mut names = std::collections::BTreeSet::new();
87    while reader.peek().kind != TokenKind::Eof {
88        if reader.take(&TokenKind::Semicolon) {
89            continue;
90        }
91        let parameter = reader.parameter()?;
92        if !names.insert(parameter.name.clone()) {
93            return Err(error(
94                parameter.span,
95                format!("duplicate configuration parameter `{}`", parameter.name),
96            ));
97        }
98        result.push(parameter);
99    }
100    Ok(result)
101}
102
103/// Validate supplied names and types independently of missing required values.
104pub fn validate_inputs(
105    parameters: &[ConfigParameter],
106    inputs: &BTreeMap<String, String>,
107    file: FileId,
108) -> Result<(), CompileError> {
109    for name in inputs.keys() {
110        if !parameters.iter().any(|parameter| &parameter.name == name) {
111            return Err(error(
112                Span::at(file),
113                format!(
114                    "unknown configuration parameter `{name}`; declare it in src/config.ts or remove the override with --reset-config {name}"
115                ),
116            ));
117        }
118    }
119    for parameter in parameters {
120        if let Some(input) = inputs.get(&parameter.name) {
121            parse_input(parameter, input)?;
122        }
123    }
124    Ok(())
125}
126
127pub fn resolve(
128    parameters: &[ConfigParameter],
129    inputs: &BTreeMap<String, String>,
130    file: FileId,
131) -> Result<BTreeMap<String, ConfigValue>, CompileError> {
132    validate_inputs(parameters, inputs, file)?;
133    let mut values = BTreeMap::new();
134    let mut missing = Vec::new();
135    for parameter in parameters {
136        let value = if let Some(input) = inputs.get(&parameter.name) {
137            Some(parse_input(parameter, input)?)
138        } else {
139            parameter.default.clone()
140        };
141        if let Some(value) = value {
142            values.insert(parameter.name.clone(), value);
143        } else {
144            missing.push(Diagnostic {
145                severity: Severity::Error,
146                span: parameter.span,
147                message: format!(
148                    "missing required configuration parameter `{}` ({})",
149                    parameter.name, parameter.type_name
150                ),
151                help: vec![format!(
152                    "supply --config {}=<{}> for this package",
153                    parameter.name, parameter.type_name
154                )],
155                notes: Vec::new(),
156            });
157        }
158    }
159    if !missing.is_empty() {
160        return Err(missing.into());
161    }
162    Ok(values)
163}
164
165pub(crate) fn resolved_tokens(
166    source: &str,
167    file: FileId,
168    inputs: &BTreeMap<String, String>,
169) -> Result<Vec<Token>, CompileError> {
170    let parameters = parameters(source, file)?;
171    let values = resolve(&parameters, inputs, file)?;
172    let mut tokens = Vec::new();
173    for parameter in parameters {
174        tokens.extend(parameter.header);
175        tokens.push(Token::new(TokenKind::Equals, parameter.value_span));
176        let value = values.get(&parameter.name).ok_or_else(|| {
177            error(
178                parameter.span,
179                "configuration resolution omitted a parameter".into(),
180            )
181        })?;
182        tokens.push(Token::new(value.token(), parameter.value_span));
183        tokens.push(Token::new(TokenKind::Semicolon, parameter.value_span));
184    }
185    tokens.push(Token::new(
186        TokenKind::Eof,
187        Span {
188            file,
189            start: source.len() as u32,
190            end: source.len() as u32,
191        },
192    ));
193    Ok(tokens)
194}
195
196fn parse_input(parameter: &ConfigParameter, input: &str) -> Result<ConfigValue, CompileError> {
197    let invalid = || {
198        error(
199            parameter.span,
200            format!(
201                "configuration parameter `{}` requires {}; supply --config {}=<{}>",
202                parameter.name, parameter.type_name, parameter.name, parameter.type_name
203            ),
204        )
205    };
206    match parameter.type_name {
207        "string" => Ok(ConfigValue::String(string_value::encode(input))),
208        "number" => input
209            .parse::<f64>()
210            .ok()
211            .filter(|value| value.is_finite())
212            .map(ConfigValue::Number)
213            .ok_or_else(invalid),
214        "boolean" => input
215            .parse::<bool>()
216            .map(ConfigValue::Boolean)
217            .map_err(|_| invalid()),
218        _ => Err(invalid()),
219    }
220}
221
222fn error(span: Span, message: String) -> CompileError {
223    vec![Diagnostic { severity: Severity::Error, span, message, help: vec!["src/config.ts accepts only exported const primitive literals and exported declare const primitive types".into()], notes: Vec::new() }].into()
224}
225
226struct ConfigReader<'a> {
227    source: &'a str,
228    tokens: &'a [Token],
229    pos: usize,
230}
231
232impl ConfigReader<'_> {
233    fn parameter(&mut self) -> Result<ConfigParameter, CompileError> {
234        let export = self.expect(&TokenKind::Export)?;
235        let declared = self.text(self.peek()) == "declare";
236        if declared {
237            self.advance();
238        }
239        let constant = self.expect(&TokenKind::Const)?;
240        let name = self.expect(&TokenKind::Identifier)?;
241        let mut header = vec![export, constant, name.clone()];
242        let annotation = if self.peek().kind == TokenKind::Colon {
243            header.push(self.advance());
244            let token = self.expect(&TokenKind::Identifier)?;
245            let type_name = match self.text(&token) {
246                "string" => "string",
247                "number" => "number",
248                "boolean" => "boolean",
249                _ => {
250                    return Err(error(
251                        token.span,
252                        "configuration types must be string, number, or boolean".into(),
253                    ));
254                }
255            };
256            header.push(token);
257            Some(type_name)
258        } else {
259            None
260        };
261        let value_span = self.peek().span;
262        let default = if declared {
263            if annotation.is_none() {
264                return Err(error(
265                    name.span,
266                    "required configuration needs an explicit primitive type".into(),
267                ));
268            }
269            None
270        } else {
271            self.expect(&TokenKind::Equals)?;
272            let negative = self.take(&TokenKind::Minus);
273            let literal = self.advance();
274            let value = match literal.kind {
275                TokenKind::StringLiteral(value) | TokenKind::TemplateNoSubstitution(value)
276                    if !negative =>
277                {
278                    ConfigValue::String(value)
279                }
280                TokenKind::NumberLiteral(value) if value.is_finite() => {
281                    ConfigValue::Number(if negative { -value } else { value })
282                }
283                TokenKind::BooleanLiteral(value) if !negative => ConfigValue::Boolean(value),
284                _ => {
285                    return Err(error(
286                        literal.span,
287                        "configuration defaults must be primitive literals".into(),
288                    ));
289                }
290            };
291            if annotation.is_some_and(|name| name != value.type_name()) {
292                return Err(error(
293                    literal.span,
294                    "configuration default does not match its declared type".into(),
295                ));
296            }
297            Some(value)
298        };
299        if self.peek().kind != TokenKind::Eof {
300            self.expect(&TokenKind::Semicolon)?;
301        }
302        let type_name = annotation
303            .or_else(|| default.as_ref().map(ConfigValue::type_name))
304            .ok_or_else(|| {
305                error(
306                    name.span,
307                    "configuration parameter needs a type or default".into(),
308                )
309            })?;
310        Ok(ConfigParameter {
311            name: self.text(&name).to_owned(),
312            type_name,
313            default,
314            span: name.span,
315            header,
316            value_span,
317        })
318    }
319
320    fn peek(&self) -> &Token {
321        // The lexer always appends EOF, and advance never advances past it.
322        &self.tokens[self.pos]
323    }
324
325    fn advance(&mut self) -> Token {
326        let token = self.peek().clone();
327        if token.kind != TokenKind::Eof {
328            self.pos += 1;
329        }
330        token
331    }
332
333    fn take(&mut self, kind: &TokenKind) -> bool {
334        if &self.peek().kind != kind {
335            return false;
336        }
337        self.advance();
338        true
339    }
340
341    fn expect(&mut self, kind: &TokenKind) -> Result<Token, CompileError> {
342        if &self.peek().kind != kind {
343            return Err(error(
344                self.peek().span,
345                format!("invalid configuration declaration: expected {kind:?}"),
346            ));
347        }
348        Ok(self.advance())
349    }
350
351    fn text(&self, token: &Token) -> &str {
352        // Token spans are produced by Asi from this source and are not changed.
353        &self.source[token.span.start as usize..token.span.end as usize]
354    }
355}
356
357/// Metadata uses ordinary JSON strings where possible, with an explicit UTF-16
358/// representation only when a default contains an unpaired surrogate.
359mod string_value {
360    use crate::literal_units::{literal_units, push_literal_char, push_lone_surrogate};
361    use serde::{Deserialize, Deserializer, Serialize, Serializer};
362
363    #[derive(Serialize, Deserialize)]
364    #[serde(untagged)]
365    enum Wire {
366        Text(String),
367        Units { utf16: Vec<u16> },
368    }
369
370    pub fn encode(text: &str) -> String {
371        let mut literal = String::new();
372        for character in text.chars() {
373            push_literal_char(&mut literal, character);
374        }
375        literal
376    }
377
378    pub fn serialize<S: Serializer>(text: &str, serializer: S) -> Result<S::Ok, S::Error> {
379        let units = literal_units(text);
380        match String::from_utf16(&units) {
381            Ok(text) => Wire::Text(text).serialize(serializer),
382            Err(_) => Wire::Units { utf16: units }.serialize(serializer),
383        }
384    }
385
386    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
387        match Wire::deserialize(deserializer)? {
388            Wire::Text(text) => Ok(encode(&text)),
389            Wire::Units { utf16 } => {
390                let mut literal = String::new();
391                for character in char::decode_utf16(utf16) {
392                    match character {
393                        Ok(character) => push_literal_char(&mut literal, character),
394                        Err(error) => {
395                            push_lone_surrogate(
396                                &mut literal,
397                                u32::from(error.unpaired_surrogate()),
398                            );
399                        }
400                    }
401                }
402                Ok(literal)
403            }
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn defaults_required_values_and_primitive_validation() {
414        let source = "// Configuration\nexport declare const url: string;\nexport const timeout = -5000;\nexport const enabled: boolean = false;\nexport const title = `hello`;";
415        let parameters = parameters(source, FileId(0)).unwrap();
416        assert!(resolve(&parameters, &BTreeMap::new(), FileId(0)).is_err());
417        let inputs = BTreeMap::from([
418            ("url".into(), "https://example.test/?a=b".into()),
419            ("timeout".into(), "0.5".into()),
420            ("enabled".into(), "true".into()),
421        ]);
422        let values = resolve(&parameters, &inputs, FileId(0)).unwrap();
423        assert_eq!(
424            values["url"],
425            ConfigValue::String("https://example.test/?a=b".into())
426        );
427        assert_eq!(values["enabled"], ConfigValue::Boolean(true));
428        let tokens = resolved_tokens(source, FileId(0), &inputs).unwrap();
429        let (_, diagnostics) = crate::parser::parse_checked(source, tokens, FileId(0)).unwrap();
430        assert!(diagnostics.is_empty(), "{diagnostics:?}");
431        for (name, value) in [
432            ("timeout", "NaN"),
433            ("timeout", "inf"),
434            ("enabled", "yes"),
435            ("unknown", "value"),
436        ] {
437            let mut invalid = inputs.clone();
438            invalid.insert(name.into(), value.into());
439            assert!(
440                resolve(&parameters, &invalid, FileId(0)).is_err(),
441                "{name}={value}"
442            );
443        }
444    }
445
446    #[test]
447    fn rejects_executable_configuration_and_conflicting_declarations() {
448        for source in [
449            "export let x = 1;",
450            "const x = 1;",
451            "export const x = 1 + 2;",
452            "export const x = Math.random();",
453            "export const x = {};",
454            "export declare const x;",
455            "export declare const x: string | undefined;",
456            "export const x: number = 'text';",
457            "export const x = 1; export const x = 2;",
458            "import { x } from './other';",
459            "export function x(): number { return 1; }",
460        ] {
461            assert!(parameters(source, FileId(0)).is_err(), "{source}");
462        }
463    }
464}
465
466#[cfg(test)]
467mod representation_tests {
468    use super::*;
469
470    #[test]
471    fn signed_zero_survives_defaults_overrides_and_metadata() {
472        let parameters = parameters("export const value = -0;", FileId(0)).unwrap();
473        for inputs in [
474            BTreeMap::new(),
475            BTreeMap::from([("value".into(), "-0".into())]),
476        ] {
477            let values = resolve(&parameters, &inputs, FileId(0)).unwrap();
478            assert_eq!(values["value"], ConfigValue::Number(-0.0));
479            assert_ne!(values["value"], ConfigValue::Number(0.0));
480            let text = serde_json::to_string(&values).unwrap();
481            let decoded: BTreeMap<String, ConfigValue> = serde_json::from_str(&text).unwrap();
482            assert_eq!(decoded, values);
483        }
484    }
485
486    #[test]
487    fn marker_characters_and_lone_surrogates_roundtrip_without_loss() {
488        let source = "export const text = '\\ud800';";
489        let parameters = parameters(source, FileId(0)).unwrap();
490        let default = resolve(&parameters, &BTreeMap::new(), FileId(0)).unwrap();
491        let encoded = serde_json::to_string(&default).unwrap();
492        assert!(encoded.contains("utf16"));
493        let decoded: BTreeMap<String, ConfigValue> = serde_json::from_str(&encoded).unwrap();
494        assert_eq!(decoded, default);
495        let input = "\u{10fffe}\u{100000}";
496        let values = resolve(
497            &parameters,
498            &BTreeMap::from([("text".into(), input.into())]),
499            FileId(0),
500        )
501        .unwrap();
502        let ConfigValue::String(text) = &values["text"] else {
503            panic!("string");
504        };
505        assert_eq!(
506            crate::literal_units::literal_units(text),
507            input.encode_utf16().collect::<Vec<_>>()
508        );
509        assert_eq!(serde_json::to_value(&values).unwrap()["text"], input);
510        let decoded: BTreeMap<String, ConfigValue> =
511            serde_json::from_value(serde_json::to_value(&values).unwrap()).unwrap();
512        assert_eq!(decoded, values);
513    }
514}