diff --git a/README.md b/README.md index efc064e7..cf5bb419 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ These are the current high-priority goals for oxabl tooling. As it stands, oxabl - Lint rule engine - Public API for creating new lint rules and submitting them upstream for inclusion in oxabl's default rule set - Status: a first set of rules ships today — `undefined-symbol` (LINT0001), `unused-variable` (LINT0002), `unknown-table-or-field` (LINT0003, live under a loaded `.df` schema), `type-mismatch-assignment` (LINT0004), `block-var-used-outside` (LINT0005), and `assigned-but-never-read` (LINT0006) — configurable per-project via `oxabl.toml`. LINT0002 and LINT0006 divide one population between them: a variable never referenced at all is LINT0002's, while one that is written and never read is a dead store reported by LINT0006 at the assignment, so silencing `unused-variable` alone no longer silences the write-only half. Around thirty ABL statement forms are recognized by the parser but not modelled (`PUT`, `EXPORT`, `UPDATE`, `ENABLE`, embedded SQL, …); their identifiers are now harvested lexically and best-effort-resolved, so all three count-gated rules stay quiet about a variable one of them touches rather than reporting it wrongly. That suppression is coarse — per-symbol and file-wide — so `oxabl analyze` reports how many symbols it could not fully judge. The handful of forms that name a *table* without reading a field of it (`DEFINE BUFFER`, `DEFINE PARAMETER BUFFER`, `EMPTY TEMP-TABLE`, `DEFINE QUERY`, `OPEN QUERY`) are treated more precisely: they credit a real read on the table, so a temp-table used only that way no longer looks untouched. Surfaced in-editor through the VS Code extension and as diagnostics from `oxabl check`. Still experimental; no public API for extending yet. + - **An `.i` opened directly is analyzed as a fragment.** Names its missing includer could supply stay silent, as do the three whole-unit count rules, while parse errors and locally provable findings still surface. The CLI and analyze coverage channels say that the includer was absent; an `.i` expanded through a `.p`/`.w`/`.cls`/`.v` root remains ordinary textual input to that complete compilation unit. - **`undefined-symbol` now reports names absent from your configured search paths.** With a workspace index attached — which is every `oxabl check` run — a `USING` import, a `NEW`, or a literal `RUN` target that no configured path supplies is reported at error severity. ABL cannot reference a symbol or a procedure whose code is not on the PROPATH, so the name genuinely is undefined rather than merely unseen. Set your paths in `[workspace.sources].include_paths` in `oxabl.toml` (relative to that file, searched in order, first match wins), or pass directories with `-I`; every such finding carries a help line naming that configuration, because a missing source root produces the same finding as missing code. Four things stay deliberately silent: names in the `Progress.*`, `OpenEdge.*`, `System.*`, and `Microsoft.*` namespaces, which ship with the AVM and have no source on any path; a runtime-computed `RUN VALUE(...)` target, which no indexing could resolve; a member a class declares but does not expose to the caller, which exists and so is an access question rather than a missing name; and every cross-file name at all when no path is configured, since "we did not look" is then the only truthful answer. The `AS CLASS pkg.Missing` declaration spelling is also silent for now — the AST carries no span for that name, so there is nothing to underline. - Easy one-line installer and VS Code extension for getting started - Status: VS Code extension available (experimental, sideload) — build a VSIX with `clients/vscode/scripts/build-vsix.sh`; it launches `oxabl lsp` for format-on-save, live diagnostics, and `oxabl.toml` schema completion. One-line installer: not started. diff --git a/crates/oxabl/src/main.rs b/crates/oxabl/src/main.rs index c0b645f1..b32f5ce6 100644 --- a/crates/oxabl/src/main.rs +++ b/crates/oxabl/src/main.rs @@ -351,6 +351,22 @@ fn surface_unjudged_symbols(sem: &oxabl_semantic::Semantic) -> usize { n } +/// Say when root-fragment analysis withheld whole-unit lint questions. +/// +/// This is coverage, not a finding: it never changes the exit code. The note is +/// emitted only for an explicitly analyzed `.i`, so ordinary project walks do +/// not grow a line users would learn to ignore. +fn surface_source_context(sem: &oxabl_semantic::Semantic) -> bool { + if !sem.source_context.is_include_fragment() { + return false; + } + eprintln!( + "note: include fragment analyzed without an includer — unresolved local names were \ + treated as external, and unused-variable, dead-store and block-variable rules stayed silent." + ); + true +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -664,6 +680,10 @@ struct CheckJsonReport { format: CheckJsonFormat, preproc: Vec, unjudged_symbols: usize, + /// Explicit include-fragment roots whose whole-unit lint rules were + /// withheld. Omitted for ordinary walks to keep their JSON stable. + #[serde(skip_serializing_if = "Vec::is_empty")] + fragment_roots: Vec, failures: Vec, } @@ -867,6 +887,7 @@ fn run_check( let mut failures: Vec = Vec::new(); let mut drifted: Vec = Vec::new(); let mut unjudged = 0usize; + let mut fragment_roots = Vec::new(); for (file, indexed_path) in files.iter().zip(&indexed) { let display = file.display().to_string(); @@ -950,7 +971,11 @@ fn run_check( // rules'* coverage, so it has nothing to qualify when those findings // are suppressed. if !no_lint && let Some(sem) = result.semantic() { - unjudged += surface_unjudged_symbols(sem); + if surface_source_context(sem) { + fragment_roots.push(display.clone()); + } else { + unjudged += surface_unjudged_symbols(sem); + } } } @@ -998,6 +1023,7 @@ fn run_check( }, preproc, unjudged_symbols: unjudged, + fragment_roots, failures, }; match serde_json::to_string_pretty(&report) { @@ -1201,7 +1227,9 @@ fn run_analyze( // saying how much of the file the count-gated rules could not judge. The // envelope carries both facts as well; neither replaces the other. surface_collected_preproc(path, &source, &collected); - surface_unjudged_symbols(sem); + if !surface_source_context(sem) { + surface_unjudged_symbols(sem); + } match format { "json" => { diff --git a/crates/oxabl/tests/parity_cli.rs b/crates/oxabl/tests/parity_cli.rs index bccac83b..c4b55ddb 100644 --- a/crates/oxabl/tests/parity_cli.rs +++ b/crates/oxabl/tests/parity_cli.rs @@ -199,6 +199,23 @@ fn the_clean_fixture_reports_nothing_and_exits_zero() { assert_eq!(code, Some(0)); } +#[test] +fn an_explicit_include_root_reports_its_reduced_coverage() { + let fixture = fixtures::fixture(fixtures::INCLUDE_FRAGMENT_FIXTURE); + let case = case(fixture); + let (report, _code, stderr) = check_json(&case, &["--no-format"]); + + fixture.assert_diagnostics("cli fragment coverage", observed(&report)); + assert_eq!( + report["fragment_roots"][0], + case.source.display().to_string() + ); + assert!( + stderr.contains("include fragment analyzed without an includer"), + "got: {stderr}" + ); +} + /// The parse-error fixture's *recovered* set survives the CLI: the parse error is /// reported and the lint pass still ran over the recovered tree. #[test] diff --git a/crates/oxabl_analyze/src/collect.rs b/crates/oxabl_analyze/src/collect.rs index bf187392..1c90c819 100644 --- a/crates/oxabl_analyze/src/collect.rs +++ b/crates/oxabl_analyze/src/collect.rs @@ -39,7 +39,9 @@ use oxabl_lexer::tokenize; use oxabl_parser::Parser; use oxabl_preprocessor::{Preprocessor, SpanNode}; use oxabl_schema::Schema; -use oxabl_semantic::{AnalysisContext, NullIndex, Semantic, WorkspaceIndex, analyze_file}; +use oxabl_semantic::{ + AnalysisContext, NullIndex, Semantic, SourceContext, WorkspaceIndex, analyze_file, +}; use oxabl_workspace::FileSystem; /// Which pipeline stage produced a diagnostic. Lets the CLI route preprocessor @@ -344,6 +346,29 @@ pub fn collect_from_expanded( schema_loaded: bool, lint_severities: &LintSeverityMap, index: &dyn WorkspaceIndex, +) -> (Option, CollectedDiagnostics) { + collect_from_expanded_with_source_context( + expanded, + schema, + schema_loaded, + lint_severities, + index, + SourceContext::CompilationUnit, + ) +} + +/// [`collect_from_expanded`] with an explicit root-source classification. +/// +/// The compatibility entry point above keeps compilation-unit behavior for +/// callers without a root path. The shared pipeline uses this entry point when +/// it knows that an explicitly opened root is an include fragment. +pub fn collect_from_expanded_with_source_context( + expanded: &ExpandedFile, + schema: &Schema, + schema_loaded: bool, + lint_severities: &LintSeverityMap, + index: &dyn WorkspaceIndex, + source_context: SourceContext, ) -> (Option, CollectedDiagnostics) { let root = expanded.root; let mut out = CollectedDiagnostics::default(); @@ -378,7 +403,8 @@ pub fn collect_from_expanded( // caller's explicit answer, which is the whole point of the flag. let mut ctx = AnalysisContext::new(root, &expanded.text, schema) .with_lint_severities(lint_severities.clone()) - .with_index(index); + .with_index(index) + .with_source_context(source_context); ctx.schema_loaded = schema_loaded; let sem = analyze_file(&program.statements, &ctx); diff --git a/crates/oxabl_analyze/src/lib.rs b/crates/oxabl_analyze/src/lib.rs index 98afd0ac..e974f895 100644 --- a/crates/oxabl_analyze/src/lib.rs +++ b/crates/oxabl_analyze/src/lib.rs @@ -40,7 +40,7 @@ //! "references": 2, //! "diagnostics": 1, //! "preproc": 1, -//! "coverage": 1, +//! "coverage": 2, //! "dependencies": 2 //! }, //! "schema_revision": 0, @@ -65,7 +65,8 @@ mod collect; pub use collect::{ CollectedDiagnostic, CollectedDiagnostics, DiagnosticSource, ExpandedFile, collect_diagnostics, - collect_from_expanded, collect_with_model, expand_source, is_loud, + collect_from_expanded, collect_from_expanded_with_source_context, collect_with_model, + expand_source, is_loud, }; use oxabl_ast::{NodeId, Statement}; @@ -108,6 +109,8 @@ pub const ENVELOPE_VERSION: u32 = 1; /// resolved to, so a cross-file resolution is distinguishable from a local one. /// * `preproc` 1, `coverage` 1 — sections promoted from keys the CLI used to /// splice in after the fact. +/// * `coverage` 2 — fragment roots add `source_context: "include_fragment"` +/// because their count-gated lint rules deliberately do not run. /// * `dependencies` 1 — cross-file *index* state: which files the run consulted /// and which class lookups came back empty. Its own section because it is a /// property of neither a symbol nor a reference. @@ -123,7 +126,7 @@ fn section_versions() -> Value { sections.insert("references".into(), json!(2)); sections.insert("diagnostics".into(), json!(1)); sections.insert("preproc".into(), json!(1)); - sections.insert("coverage".into(), json!(1)); + sections.insert("coverage".into(), json!(2)); sections.insert("dependencies".into(), json!(2)); Value::Object(sections) } @@ -287,6 +290,9 @@ fn write_coverage(out: &mut String, sem: &Semantic) { use std::fmt::Write; writeln!(out, "\n=== Coverage ===").ok(); writeln!(out, " unjudged symbols: {}", unjudged_symbol_count(sem)).ok(); + if sem.source_context.is_include_fragment() { + writeln!(out, " source context: include_fragment").ok(); + } } /// The `dependencies` section's text form: the run's cross-file index state. @@ -743,7 +749,12 @@ fn preproc_json(collected: &CollectedDiagnostics) -> Value { /// finished document, and a second such fact would have meant a second splice — /// which is the pattern this section exists to end. fn coverage_json(sem: &Semantic) -> Value { - json!({ "unjudged_symbols": unjudged_symbol_count(sem) }) + let mut coverage = Map::new(); + coverage.insert("unjudged_symbols".into(), json!(unjudged_symbol_count(sem))); + if sem.source_context.is_include_fragment() { + coverage.insert("source_context".into(), json!(sem.source_context.as_str())); + } + Value::Object(coverage) } /// One other file this run consulted, and what linked it. @@ -1094,7 +1105,7 @@ mod tests { use oxabl_ast::{DataType, Identifier, Span, Statement, StatementKind, TypeSource}; use oxabl_common::FileId; use oxabl_schema::Schema; - use oxabl_semantic::analyze_file; + use oxabl_semantic::{SourceContext, analyze_file}; fn ident(n: &str) -> Identifier { Identifier { @@ -1570,7 +1581,7 @@ mod tests { ("types", 1), ("diagnostics", 1), ("preproc", 1), - ("coverage", 1), + ("coverage", 2), ] { assert_eq!(sections[name], version, "{name} must not have moved"); } @@ -1594,6 +1605,20 @@ mod tests { ); } + #[test] + fn fragment_context_is_an_explicit_coverage_fact() { + let schema = Schema::empty(); + let statements = vec![var_decl("x", DataType::Integer)]; + let ctx = AnalysisContext::new(FileId::UNKNOWN, "", &schema) + .with_source_context(SourceContext::IncludeFragment); + let sem = analyze_file(&statements, &ctx); + let json = dump_json(&statements, &sem, &ctx, true); + let text = dump_text(&statements, &sem, &ctx); + + assert_eq!(json["coverage"]["source_context"], "include_fragment"); + assert!(text.contains("source context: include_fragment")); + } + /// Both text siblings render the section. `preproc` and `coverage` were /// JSON-only for as long as the CLI spliced them in, which made /// `--format text` strictly less informative about the same run; do not diff --git a/crates/oxabl_lint/src/lib.rs b/crates/oxabl_lint/src/lib.rs index bac05802..7b365eb1 100644 --- a/crates/oxabl_lint/src/lib.rs +++ b/crates/oxabl_lint/src/lib.rs @@ -51,21 +51,26 @@ pub fn lint_file(program: &[Statement], sem: &Semantic, ctx: &AnalysisContext) - run_rule(&mut diags, LINT0001, ctx, || { undefined_symbol::run(program, sem, ctx) }); - run_rule(&mut diags, LINT0002, ctx, || { - unused_variable::run(program, sem, ctx) - }); + let counts_are_complete = !ctx.source_context.is_include_fragment(); + if counts_are_complete { + run_rule(&mut diags, LINT0002, ctx, || { + unused_variable::run(program, sem, ctx) + }); + } run_rule(&mut diags, LINT0003, ctx, || { unknown_table_or_field::run(program, sem, ctx) }); run_rule(&mut diags, LINT0004, ctx, || { type_mismatch_assignment::run(program, sem, ctx) }); - run_rule(&mut diags, LINT0005, ctx, || { - block_var_used_outside::run(program, sem, ctx) - }); - run_rule(&mut diags, LINT0006, ctx, || { - assigned_but_never_read::run(program, sem, ctx) - }); + if counts_are_complete { + run_rule(&mut diags, LINT0005, ctx, || { + block_var_used_outside::run(program, sem, ctx) + }); + run_rule(&mut diags, LINT0006, ctx, || { + assigned_but_never_read::run(program, sem, ctx) + }); + } diags } diff --git a/crates/oxabl_lsp/src/db.rs b/crates/oxabl_lsp/src/db.rs index 1d78fd1e..ea9c4fcc 100644 --- a/crates/oxabl_lsp/src/db.rs +++ b/crates/oxabl_lsp/src/db.rs @@ -761,11 +761,18 @@ fn diagnostics(db: &dyn AblDatabase, buffer: Buffer, schema: SchemaHandle) -> Co // own diagnostics under `DiagnosticSource::Preproc`, so there is no second // mapping to keep in step here. `into_diagnostics` takes the set rather than // cloning it — this runs per keystroke. - db.config() - .lint_pipeline() - .with_index(&index) - .collect(&expansion) - .into_diagnostics() + let run = db.config().lint_pipeline(); + match buffer.path(db).as_deref() { + Some(path) => run + .with_file(path) + .with_index(&index) + .collect(&expansion) + .into_diagnostics(), + None => run + .with_index(&index) + .collect(&expansion) + .into_diagnostics(), + } } /// Compute diagnostics on a snapshot, swallowing a [`salsa::Cancelled`] unwind diff --git a/crates/oxabl_pipeline/src/fixtures.rs b/crates/oxabl_pipeline/src/fixtures.rs index bf4255f5..f78a405f 100644 --- a/crates/oxabl_pipeline/src/fixtures.rs +++ b/crates/oxabl_pipeline/src/fixtures.rs @@ -106,6 +106,9 @@ pub enum Capability { /// runs with preprocessing off and no filesystem, so no `PREPROC007` can /// ever be produced there. IncludeResolution, + /// A root filename. The browser export accepts source text only, so it + /// cannot distinguish a complete compilation unit from an include fragment. + RootFileIdentity, } /// A diagnostic a fixture must produce, in the pipeline's own coordinates. @@ -794,6 +797,9 @@ pub fn config_with_override() -> PipelineConfig { /// own, on a source where getting it wrong shows up at all. pub const NON_ASCII_FIXTURE: &str = "non_ascii_prefix"; +/// The fixture whose `.i` root selects include-fragment analysis. +pub const INCLUDE_FRAGMENT_FIXTURE: &str = "include_fragment_root"; + /// The 1-based line the non-ASCII fixture's finding sits on. pub const NON_ASCII_LINE: usize = 2; @@ -815,6 +821,44 @@ pub const NON_ASCII_CHARACTER_COLUMN: usize = 30; /// Every fixture, shared by all four legs. pub const FIXTURES: &[ParityFixture] = &[ + ParityFixture { + name: INCLUDE_FRAGMENT_FIXTURE, + root_file: "fragment.i", + siblings: &[], + resolutions: &[], + source: "DEFINE VARIABLE local AS INTEGER NO-UNDO.\n\ + local = \"wrong\".\n\ + outside = local.\n\ + DEFINE VARIABLE unused AS INTEGER NO-UNDO.\n\ + DEFINE VARIABLE dead AS INTEGER NO-UNDO.\n\ + dead = 1.\n\ + DO:\n\ + DEFINE VARIABLE escaped AS INTEGER NO-UNDO.\n\ + escaped = 1.\n\ + END.\n\ + MESSAGE escaped.\n", + diagnostics: &[ExpectedDiagnostic { + code: "LINT0004", + severity: Severity::Warning, + source: DiagnosticSource::Lint, + start: 42, + end: 47, + }], + format: ExpectedFormat::Reformatted(concat!( + "DEFINE VARIABLE local AS INTEGER NO-UNDO.\n", + "local = \"wrong\".\n", + "outside = local.\n", + "DEFINE VARIABLE unused AS INTEGER NO-UNDO.\n", + "DEFINE VARIABLE dead AS INTEGER NO-UNDO.\n", + "dead = 1.\n", + "DO:\n", + " DEFINE VARIABLE escaped AS INTEGER NO-UNDO.\n", + " escaped = 1.\n", + "END.\n", + "MESSAGE escaped.\n", + )), + needs: &[Capability::RootFileIdentity], + }, ParityFixture { name: "undefined_symbol", root_file: "main.p", diff --git a/crates/oxabl_pipeline/src/lint.rs b/crates/oxabl_pipeline/src/lint.rs index d6269fd5..f4725390 100644 --- a/crates/oxabl_pipeline/src/lint.rs +++ b/crates/oxabl_pipeline/src/lint.rs @@ -79,14 +79,14 @@ use std::path::{Path, PathBuf}; use oxabl_analyze::{ CollectedDiagnostic, CollectedDiagnostics, DiagnosticSource, ExpandedFile, - collect_from_expanded, expand_source, + collect_from_expanded_with_source_context, expand_source, }; use oxabl_common::{ Diagnostic, FileId, InternalPanic, catch_panic, panic_if_injected, panic_sites, }; use oxabl_index::BatchIndex; -use oxabl_semantic::{Semantic, WorkspaceIndex}; -use oxabl_workspace::FileSystem; +use oxabl_semantic::{Semantic, SourceContext, WorkspaceIndex}; +use oxabl_workspace::{FileSystem, is_include_fragment}; use crate::{PipelineConfig, ROOT_FILE_ID}; @@ -621,13 +621,19 @@ impl<'a> LintPipeline<'a> { let dependency_paths = expansion.dependency_paths().to_vec(); match &expansion.inner { Ok(expanded) => { + let source_context = if self.file().is_some_and(is_include_fragment) { + SourceContext::IncludeFragment + } else { + SourceContext::CompilationUnit + }; let (semantic, diagnostics) = self.with_run_index(|index| { - collect_from_expanded( + collect_from_expanded_with_source_context( expanded, &self.config.schema, self.config.schema_loaded, &self.config.lint_severities, index, + source_context, ) }); LintResult::computed(semantic.map(Box::new), diagnostics, dependency_paths) @@ -740,6 +746,42 @@ mod tests { assert!(result.semantic().is_some(), "model must still be built"); } + #[test] + fn fragment_mode_softens_only_includer_dependent_names() { + let fs = InMemoryFileSystem::new(); + let config = PipelineConfig { + include_paths: vec![PathBuf::from("/workspace")], + ..PipelineConfig::default() + }; + let source = "DEFINE VARIABLE value AS Object NO-UNDO.\n\ + value = NEW Missing().\n\ + value = NEW pkg.Missing().\n\ + RUN missing-program.p.\n\ + outside = value.\n"; + let run = LintPipeline::new(&config, &fs); + let fragment = run.with_file("/workspace/fragment.I").run(source); + let undefined: Vec<_> = fragment + .all() + .filter(|d| d.diagnostic.code.0 == "LINT0001") + .map(|d| d.diagnostic.message.as_str()) + .collect(); + + assert_eq!(undefined.len(), 2, "got {undefined:?}"); + assert!(undefined.iter().any(|m| m.contains("pkg.Missing"))); + assert!(undefined.iter().any(|m| m.contains("missing-program.p"))); + assert!(!undefined.iter().any(|m| m.contains("`Missing`"))); + assert!(!undefined.iter().any(|m| m.contains("outside"))); + + let unit = run.with_file("/workspace/program.p").run(source); + assert!( + unit.all() + .filter(|d| d.diagnostic.code.0 == "LINT0001") + .count() + > undefined.len(), + "the compilation-unit control must retain local and unqualified misses" + ); + } + // The loud unresolvable-include warning must be reachable by source, not // silently folded into the general set. #[test] diff --git a/crates/oxabl_semantic/src/index_vec.rs b/crates/oxabl_semantic/src/index_vec.rs index cbb4aa67..18cc6845 100644 --- a/crates/oxabl_semantic/src/index_vec.rs +++ b/crates/oxabl_semantic/src/index_vec.rs @@ -71,6 +71,14 @@ impl NodeIndexVec { .enumerate() .filter_map(|(i, slot)| slot.as_ref().map(|t| (NodeId::from_u32(i as u32), t))) } + + /// Iterate mutably over populated `(NodeId, value)` pairs. + pub fn iter_mut(&mut self) -> impl Iterator { + self.inner + .iter_mut() + .enumerate() + .filter_map(|(i, slot)| slot.as_mut().map(|t| (NodeId::from_u32(i as u32), t))) + } } #[cfg(test)] @@ -102,4 +110,17 @@ mod tests { let collected: Vec<_> = v.iter().map(|(id, t)| (id.as_u32(), *t)).collect(); assert_eq!(collected, vec![(1, 10), (4, 40)]); } + + #[test] + fn iter_mut_updates_only_populated_slots() { + let mut v = NodeIndexVec::new(); + v.insert(NodeId::from_u32(1), 10); + v.insert(NodeId::from_u32(4), 40); + for (_, value) in v.iter_mut() { + *value += 1; + } + assert_eq!(v.get(NodeId::from_u32(1)), Some(&11)); + assert_eq!(v.get(NodeId::from_u32(4)), Some(&41)); + assert!(v.get(NodeId::from_u32(2)).is_none()); + } } diff --git a/crates/oxabl_semantic/src/lib.rs b/crates/oxabl_semantic/src/lib.rs index 74f75642..c4f3bded 100644 --- a/crates/oxabl_semantic/src/lib.rs +++ b/crates/oxabl_semantic/src/lib.rs @@ -51,6 +51,32 @@ pub use types::{AblType, PrimitiveTy, ResolvedType}; use oxabl_common::{Diagnostic, FileId, LintSeverityMap, VirtualSpan}; use oxabl_schema::{Schema, SchemaRevision}; +/// What kind of source is being analyzed as the root buffer. +/// +/// Include fragments are textual splices whose surrounding declarations and +/// usage counts live in an includer that is absent when the fragment is opened +/// directly. The default remains a complete compilation unit so callers with +/// no path identity keep the established behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SourceContext { + #[default] + CompilationUnit, + IncludeFragment, +} + +impl SourceContext { + pub fn is_include_fragment(self) -> bool { + self == Self::IncludeFragment + } + + pub fn as_str(self) -> &'static str { + match self { + Self::CompilationUnit => "compilation_unit", + Self::IncludeFragment => "include_fragment", + } + } +} + /// Input to [`analyze_file`] and the per-pass entry points. /// /// The context never takes ownership of any data — the caller holds the AST, @@ -91,6 +117,9 @@ pub struct AnalysisContext<'a> { /// consults this to skip *off* rules and remap emitted severities (KTD6); /// the semantic passes themselves ignore it. pub lint_severities: LintSeverityMap, + /// Whether the root is a complete compilation unit or an include fragment + /// opened without its textual includer. + pub source_context: SourceContext, } impl<'a> AnalysisContext<'a> { @@ -110,6 +139,7 @@ impl<'a> AnalysisContext<'a> { index_loaded: false, index_searches_paths: false, lint_severities: LintSeverityMap::new(), + source_context: SourceContext::CompilationUnit, } } @@ -119,6 +149,12 @@ impl<'a> AnalysisContext<'a> { self } + /// Classify the root buffer for resolution and lint coverage. + pub fn with_source_context(mut self, source_context: SourceContext) -> Self { + self.source_context = source_context; + self + } + /// Attach a workspace index to this context (builder-style). Accepts /// anything that borrows as `&dyn WorkspaceIndex`, so a language server /// holding an `Arc` passes `&*arc`. @@ -153,6 +189,8 @@ pub struct Semantic { /// output itself. pub index_revision: IndexRevision, pub diagnostics: Vec, + /// The root-source classification this model was analyzed under. + pub source_context: SourceContext, } /// Run every semantic pass over `program` and return a [`Semantic`]. v1 @@ -161,9 +199,12 @@ pub struct Semantic { /// scope tree, symbol table, reference map, and type map. pub fn analyze_file(program: &[oxabl_ast::Statement], ctx: &AnalysisContext) -> Semantic { let (scope_tree, mut symbols, mut diagnostics, declare_revision) = declare_pass(program, ctx); - let (references, mut types, resolve_diags) = + let (mut references, mut types, resolve_diags) = resolve_pass(program, ctx, &scope_tree, &mut symbols, declare_revision); diagnostics.extend(resolve_diags); + if ctx.source_context.is_include_fragment() { + soften_includer_dependent_misses(&mut references); + } let check_diags = check_pass(program, ctx, &scope_tree, &symbols, &references, &mut types); diagnostics.extend(check_diags); Semantic { @@ -174,6 +215,26 @@ pub fn analyze_file(program: &[oxabl_ast::Statement], ctx: &AnalysisContext) -> schema_revision: ctx.schema.revision(), index_revision: ctx.index.revision(), diagnostics, + source_context: ctx.source_context, + } +} + +/// Reclassify only misses whose answer could change when this fragment is +/// textually spliced into its includer. +/// +/// A local miss is always context-dependent. An unqualified workspace miss may +/// be changed by a `USING` import in the includer. Qualified names and +/// file-shaped `RUN missing.p` targets remain hard workspace facts. +fn soften_includer_dependent_misses(references: &mut NodeIndexVec) { + for (_, resolution) in references.iter_mut() { + let Resolution::Unresolved { name, reason } = resolution else { + continue; + }; + let includer_dependent = *reason == UnresolvedReason::NotInScope + || (*reason == UnresolvedReason::AbsentFromWorkspace && !name.as_ref().contains('.')); + if includer_dependent { + *reason = UnresolvedReason::External; + } } } @@ -253,6 +314,47 @@ mod tests { assert_eq!(ctx.index.revision(), IndexRevision::ABSENT); } + #[test] + fn fragment_context_softens_local_misses_before_checking() { + let schema = Schema::empty(); + let source = "MESSAGE missing."; + let tokens = oxabl_lexer::tokenize(source); + let program = oxabl_parser::Parser::new(&tokens, source).parse_program(); + + let unit = analyze_file( + &program.statements, + &AnalysisContext::new(FileId::UNKNOWN, source, &schema), + ); + assert!(unit.references.iter().any(|(_, resolution)| matches!( + resolution, + Resolution::Unresolved { + reason: UnresolvedReason::NotInScope, + .. + } + ))); + + let fragment = analyze_file( + &program.statements, + &AnalysisContext::new(FileId::UNKNOWN, source, &schema) + .with_source_context(SourceContext::IncludeFragment), + ); + assert!(fragment.source_context.is_include_fragment()); + assert!(fragment.references.iter().any(|(_, resolution)| matches!( + resolution, + Resolution::Unresolved { + reason: UnresolvedReason::External, + .. + } + ))); + assert!(!fragment.references.iter().any(|(_, resolution)| matches!( + resolution, + Resolution::Unresolved { + reason: UnresolvedReason::NotInScope, + .. + } + ))); + } + #[test] fn with_index_marks_loaded_and_installs_the_handle() { let schema = Schema::empty(); diff --git a/crates/oxabl_wasm/src/lib.rs b/crates/oxabl_wasm/src/lib.rs index 5372f394..c4881314 100644 --- a/crates/oxabl_wasm/src/lib.rs +++ b/crates/oxabl_wasm/src/lib.rs @@ -828,6 +828,20 @@ mod tests { ); } + /// Root file identity is unavailable too: the export receives source + /// bytes only, so it cannot know that the same bytes came from an `.i`. + #[test] + fn root_file_identity_is_an_unavailable_capability() { + let fixture = fixtures::fixture(fixtures::INCLUDE_FRAGMENT_FIXTURE); + assert!(fixture.needs_capability(Capability::RootFileIdentity)); + assert!(!fixture.browser_comparable()); + assert_ne!( + observed_through_the_export(fixture.source), + fixture.expected(), + "an anonymous browser buffer must keep compilation-unit behavior" + ); + } + /// A schema is an unavailable capability for the same reason, so /// `unknown-table-or-field` is inert in the browser. #[test] diff --git a/crates/oxabl_workspace/src/discovery.rs b/crates/oxabl_workspace/src/discovery.rs index aec7782b..4a591836 100644 --- a/crates/oxabl_workspace/src/discovery.rs +++ b/crates/oxabl_workspace/src/discovery.rs @@ -46,6 +46,17 @@ use std::path::{Path, PathBuf}; /// absent (R9, see the module docs). pub const ROOT_EXTENSIONS: &[&str] = &["p", "w", "cls", "v"]; +/// Whether `path` names an ABL include fragment. +/// +/// Kept beside [`is_root_file`] so every client uses the same case-insensitive +/// extension policy when deciding whether a root buffer is a compilation unit +/// or a textual fragment opened out of its including context. +pub fn is_include_fragment(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("i")) +} + /// Whether `path`'s extension makes it an ABL root (R8). /// /// Case-insensitive: `.CLS` and `.cls` are the same root kind. ABL extensions @@ -217,6 +228,22 @@ mod tests { assert!(!is_root_file(Path::new("no_extension"))); } + #[test] + fn include_fragment_policy_is_case_insensitive_and_specific() { + for name in ["fragment.i", "fragment.I"] { + assert!(is_include_fragment(Path::new(name)), "{name}"); + } + for name in [ + "program.p", + "window.w", + "class.cls", + "legacy.v", + "notes.txt", + ] { + assert!(!is_include_fragment(Path::new(name)), "{name}"); + } + } + #[test] fn walk_returns_only_roots_sorted() { let tmp = fixture_tree(); diff --git a/crates/oxabl_workspace/src/lib.rs b/crates/oxabl_workspace/src/lib.rs index 9f6ed8cf..d9f6924f 100644 --- a/crates/oxabl_workspace/src/lib.rs +++ b/crates/oxabl_workspace/src/lib.rs @@ -5,7 +5,9 @@ mod include_paths; mod workspace; pub use config::{LintConfig, LintSeverity, WorkspaceConfig}; -pub use discovery::{ROOT_EXTENSIONS, discover_path, is_root_file, walk_directory}; +pub use discovery::{ + ROOT_EXTENSIONS, discover_path, is_include_fragment, is_root_file, walk_directory, +}; pub use file_system::{FileSystem, InMemoryFileSystem, RealFileSystem}; pub use include_paths::find_workspace_root; pub use workspace::Workspace;