fn generated_ts_optional_nullable_fields_only_in_params()

in codex-rs/app-server-protocol/src/export.rs [2104:2324]


    fn generated_ts_optional_nullable_fields_only_in_params() -> Result<()> {
        // Assert that "?: T | null" only appears in generated *Params types.
        let fixture_tree = read_schema_fixture_subtree(&schema_root()?, "typescript")?;

        let client_request_ts = std::str::from_utf8(
            fixture_tree
                .get(Path::new("ClientRequest.ts"))
                .ok_or_else(|| anyhow::anyhow!("missing ClientRequest.ts fixture"))?,
        )?;
        assert_eq!(client_request_ts.contains("mock/experimentalMethod"), false);
        assert_eq!(
            client_request_ts.contains("MockExperimentalMethodParams"),
            false
        );
        let typescript_index = std::str::from_utf8(
            fixture_tree
                .get(Path::new("index.ts"))
                .ok_or_else(|| anyhow::anyhow!("missing index.ts fixture"))?,
        )?;
        assert_eq!(typescript_index.contains("export type { EventMsg }"), false);
        let thread_start_ts = std::str::from_utf8(
            fixture_tree
                .get(Path::new("v2/ThreadStartParams.ts"))
                .ok_or_else(|| anyhow::anyhow!("missing v2/ThreadStartParams.ts fixture"))?,
        )?;
        assert_eq!(thread_start_ts.contains("mockExperimentalField"), false);
        assert_eq!(
            fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodParams.ts")),
            false
        );
        assert_eq!(
            fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")),
            false
        );
        assert_eq!(
            fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")),
            false
        );
        assert_eq!(
            fixture_tree.contains_key(Path::new("v2/RemoteControlClientsListOrder.ts")),
            false
        );

        let mut undefined_offenders = Vec::new();
        let mut optional_nullable_offenders = BTreeSet::new();
        for (path, contents) in &fixture_tree {
            if !matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) {
                continue;
            }

            // Only allow "?: T | null" in objects representing JSON-RPC requests,
            // which we assume are called "*Params".
            let allow_optional_nullable = path
                .file_stem()
                .and_then(|stem| stem.to_str())
                .is_some_and(|stem| {
                    stem.ends_with("Params")
                        || stem == "InitializeCapabilities"
                        || matches!(
                            stem,
                            "CollabAgentRef"
                                | "CollabAgentStatusEntry"
                                | "CollabAgentSpawnEndEvent"
                                | "CollabAgentInteractionEndEvent"
                                | "CollabCloseEndEvent"
                                | "CollabResumeBeginEvent"
                                | "CollabResumeEndEvent"
                        )
                });

            let contents = std::str::from_utf8(contents)?;
            if contents.contains("| undefined") {
                undefined_offenders.push(path.clone());
            }

            const SKIP_PREFIXES: &[&str] = &[
                "const ",
                "let ",
                "var ",
                "export const ",
                "export let ",
                "export var ",
            ];

            let mut search_start = 0;
            while let Some(idx) = contents[search_start..].find("| null") {
                let abs_idx = search_start + idx;
                // Find the property-colon for this field by scanning forward
                // from the start of the segment and ignoring nested braces,
                // brackets, and parens. This avoids colons inside nested
                // type literals like `{ [k in string]?: string }`.

                let line_start_idx = contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0);

                let mut segment_start_idx = line_start_idx;
                if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') {
                    segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1);
                }
                if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') {
                    segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1);
                }
                if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') {
                    segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1);
                }

                // Scan forward for the colon that separates the field name from its type.
                let mut level_brace = 0_i32;
                let mut level_brack = 0_i32;
                let mut level_paren = 0_i32;
                let mut in_single = false;
                let mut in_double = false;
                let mut escape = false;
                let mut prop_colon_idx = None;
                for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() {
                    let idx_abs = segment_start_idx + i;
                    if escape {
                        escape = false;
                        continue;
                    }
                    match ch {
                        '\\' if (in_single || in_double) => {
                            escape = true;
                        }
                        '\'' if !in_double => {
                            in_single = !in_single;
                        }
                        '"' if !in_single => {
                            in_double = !in_double;
                        }
                        '{' if !in_single && !in_double => level_brace += 1,
                        '}' if !in_single && !in_double => level_brace -= 1,
                        '[' if !in_single && !in_double => level_brack += 1,
                        ']' if !in_single && !in_double => level_brack -= 1,
                        '(' if !in_single && !in_double => level_paren += 1,
                        ')' if !in_single && !in_double => level_paren -= 1,
                        ':' if !in_single
                            && !in_double
                            && level_brace == 0
                            && level_brack == 0
                            && level_paren == 0 =>
                        {
                            prop_colon_idx = Some(idx_abs);
                            break;
                        }
                        _ => {}
                    }
                }

                let Some(colon_idx) = prop_colon_idx else {
                    search_start = abs_idx + 5;
                    continue;
                };

                let mut field_prefix = contents[segment_start_idx..colon_idx].trim();
                if field_prefix.is_empty() {
                    search_start = abs_idx + 5;
                    continue;
                }

                if let Some(comment_idx) = field_prefix.rfind("*/") {
                    field_prefix = field_prefix[comment_idx + 2..].trim_start();
                }

                if field_prefix.is_empty() {
                    search_start = abs_idx + 5;
                    continue;
                }

                if SKIP_PREFIXES
                    .iter()
                    .any(|prefix| field_prefix.starts_with(prefix))
                {
                    search_start = abs_idx + 5;
                    continue;
                }

                if field_prefix.contains('(') {
                    search_start = abs_idx + 5;
                    continue;
                }

                // If the last non-whitespace before ':' is '?', then this is an
                // optional field with a nullable type (i.e., "?: T | null").
                // These are only allowed in *Params types.
                if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?')
                    && !allow_optional_nullable
                {
                    let line_number =
                        contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1;
                    let offending_line_end = contents[line_start_idx..]
                        .find('\n')
                        .map(|i| line_start_idx + i)
                        .unwrap_or(contents.len());
                    let offending_snippet = contents[line_start_idx..offending_line_end].trim();

                    optional_nullable_offenders.insert(format!(
                        "{}:{}: {offending_snippet}",
                        path.display(),
                        line_number
                    ));
                }

                search_start = abs_idx + 5;
            }
        }

        assert!(
            undefined_offenders.is_empty(),
            "Generated TypeScript still includes unions with `undefined` in {undefined_offenders:?}"
        );

        // If this assertion fails, it means a field was generated as "?: T | null",
        // which is both optional (undefined) and nullable (null), for a type not ending
        // in "Params" (which represent JSON-RPC requests).
        assert!(
            optional_nullable_offenders.is_empty(),
            "Generated TypeScript has optional nullable fields outside *Params types (disallowed '?: T | null'):\n{optional_nullable_offenders:?}"
        );

        Ok(())
    }