Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 30 additions & 2 deletions crates/oxabl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -664,6 +680,10 @@ struct CheckJsonReport {
format: CheckJsonFormat,
preproc: Vec<CheckJsonDiagnostic>,
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<String>,
failures: Vec<CheckJsonFailure>,
}

Expand Down Expand Up @@ -867,6 +887,7 @@ fn run_check(
let mut failures: Vec<CheckJsonFailure> = Vec::new();
let mut drifted: Vec<String> = 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();
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -998,6 +1023,7 @@ fn run_check(
},
preproc,
unjudged_symbols: unjudged,
fragment_roots,
failures,
};
match serde_json::to_string_pretty(&report) {
Expand Down Expand Up @@ -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" => {
Expand Down
17 changes: 17 additions & 0 deletions crates/oxabl/tests/parity_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
30 changes: 28 additions & 2 deletions crates/oxabl_analyze/src/collect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -344,6 +346,29 @@ pub fn collect_from_expanded(
schema_loaded: bool,
lint_severities: &LintSeverityMap,
index: &dyn WorkspaceIndex,
) -> (Option<Semantic>, 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<Semantic>, CollectedDiagnostics) {
let root = expanded.root;
let mut out = CollectedDiagnostics::default();
Expand Down Expand Up @@ -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);

Expand Down
37 changes: 31 additions & 6 deletions crates/oxabl_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
//! "references": 2,
//! "diagnostics": 1,
//! "preproc": 1,
//! "coverage": 1,
//! "coverage": 2,
//! "dependencies": 2
//! },
//! "schema_revision": 0,
Expand All @@ -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};
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
Expand All @@ -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
Expand Down
23 changes: 14 additions & 9 deletions crates/oxabl_lint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
17 changes: 12 additions & 5 deletions crates/oxabl_lsp/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions crates/oxabl_pipeline/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand All @@ -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",
Expand Down
Loading
Loading