From 1e496247fbee269b79c887ff29a744bd2ef4f393 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 2 Sep 2025 04:45:26 -0700 Subject: [PATCH 001/314] Be able to override diagnostic severities via config Summary: Severity is currently hardcoded by linters and there's no mechanism to override it. This is inconvenient for two reasons: * Changing the severity for the linter requires an ELP release * The linting experience cannot be easily customized by the external community This is a first step towards a more generic linter configuration mechanism for ELP, which will incrementally expanded. Since this is currently respected only by the linters implementing the `Linter` trait, we keep the feature currently undocumented. Reviewed By: TD5 Differential Revision: D81443678 fbshipit-source-id: 17783df15cabb0f7c16493a461e457fd214b2bc6 --- crates/elp/src/bin/lint_cli.rs | 5 ++ crates/elp/src/lib.rs | 22 +++++++ crates/ide/src/diagnostics.rs | 117 ++++++++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/crates/elp/src/bin/lint_cli.rs b/crates/elp/src/bin/lint_cli.rs index 3b81a8e119..8bfd352a74 100644 --- a/crates/elp/src/bin/lint_cli.rs +++ b/crates/elp/src/bin/lint_cli.rs @@ -942,6 +942,7 @@ mod tests { use elp_ide::diagnostics::Replacement; use expect_test::Expect; use expect_test::expect; + use fxhash::FxHashMap; use super::LintConfig; use super::do_codemod; @@ -966,6 +967,7 @@ mod tests { }, enabled_lints: vec![DiagnosticCode::HeadMismatch], disabled_lints: vec![], + linters: FxHashMap::default(), }) .unwrap(); @@ -983,6 +985,8 @@ mod tests { [ad_hoc_lints.lints.action] action = "Replace" type = "UseOk" + + [linters] "#]] .assert_eq(&result); } @@ -1006,6 +1010,7 @@ mod tests { ad_hoc_lints: LintsFromConfig { lints: [], }, + linters: {}, } "#]] .assert_debug_eq(&lint_config); diff --git a/crates/elp/src/lib.rs b/crates/elp/src/lib.rs index caecb2b2f5..a75db798c2 100644 --- a/crates/elp/src/lib.rs +++ b/crates/elp/src/lib.rs @@ -164,7 +164,9 @@ mod tests { use elp_ide::diagnostics::ReplaceCall; use elp_ide::diagnostics::ReplaceCallAction; use elp_ide::diagnostics::Replacement; + use elp_ide::diagnostics::Severity; use expect_test::expect; + use fxhash::FxHashMap; use crate::LintConfig; @@ -187,6 +189,7 @@ mod tests { }), ], }, + linters: FxHashMap::default(), }; expect![[r#" enabled_lints = ["W0011"] @@ -214,6 +217,8 @@ mod tests { action = "Replace" type = "ArgsPermutation" perm = [1, 2] + + [linters] "#]] .assert_eq(&toml::to_string::(&lint_config).unwrap()); } @@ -227,4 +232,21 @@ mod tests { assert_eq!(config.enabled_lints, vec![]); assert_eq!(config.disabled_lints, vec![]); } + + #[test] + fn serde_read_lint_config_linters_overrides() { + let content = r#" + [linters.no_garbage_collect] + severity = "error" + "#; + let config = toml::from_str::(content).unwrap(); + assert_eq!( + config + .linters + .get(&DiagnosticCode::NoGarbageCollect) + .unwrap() + .severity, + Some(Severity::Error) + ); + } } diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 6555f95881..5b80b95535 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -727,6 +727,7 @@ pub struct DiagnosticsConfig { pub disabled: FxHashSet, pub enabled: EnabledDiagnostics, pub lints_from_config: LintsFromConfig, + pub lint_config: Option, pub include_generated: bool, pub include_suppressed: bool, pub include_otp: bool, @@ -786,6 +787,7 @@ impl DiagnosticsConfig { self.enabled = EnabledDiagnostics::from_set(allowed_diagnostics); } self.lints_from_config = lint_config.ad_hoc_lints.clone(); + self.lint_config = Some(lint_config.clone()); self.request_erlang_service_diagnostics = self.request_erlang_service_diagnostics(); Ok(self) } @@ -850,11 +852,18 @@ impl DiagnosticsConfig { } } +impl LintConfig { + /// Get the severity override for a linter based on its diagnostic code + pub fn get_severity_override(&self, diagnostic_code: &DiagnosticCode) -> Option { + self.linters.get(diagnostic_code)?.severity + } +} + // --------------------------------------------------------------------- /// Configuration file format for lints. Deserialized from .toml /// initially. But could by anything supported by serde. -#[derive(Deserialize, Serialize, Default, Debug)] +#[derive(Deserialize, Serialize, Default, Debug, Clone)] pub struct LintConfig { #[serde(default)] pub enabled_lints: Vec, @@ -862,6 +871,49 @@ pub struct LintConfig { pub disabled_lints: Vec, #[serde(default)] pub ad_hoc_lints: LintsFromConfig, + #[serde(default)] + pub linters: FxHashMap, +} + +/// Configuration for a specific linter that allows overriding default settings +#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Default)] +pub struct LinterConfig { + pub severity: Option, +} + +impl<'de> Deserialize<'de> for Severity { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "error" => Ok(Severity::Error), + "warning" => Ok(Severity::Warning), + "weak" => Ok(Severity::WeakWarning), + "info" => Ok(Severity::Information), + _ => Err(serde::de::Error::custom(format!( + "Unknown severity: {}. Expected one of: error, warning, info, weak", + s + ))), + } + } +} + +impl Serialize for Severity { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let s = match self { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::WeakWarning => "weak", + Severity::Information => "info", + }; + serializer.serialize_str(s) + } } // --------------------------------------------------------------------- @@ -1168,10 +1220,19 @@ fn diagnostics_from_function_call_linters( default_disabled: !linter.is_enabled(), }; if conditions.enabled(config, is_generated, is_test) { + // Check if there is a severity override in the config + let severity = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_severity_override(&linter.id()) + .unwrap_or_else(|| linter.severity()) + } else { + linter.severity() + }; + let diagnostic_template = DiagnosticTemplate { code: linter.id(), message: linter.description(), - severity: linter.severity(), + severity, with_ignore_fix: linter.can_be_suppressed(), use_range: UseRange::NameOnly, }; @@ -1210,7 +1271,20 @@ fn diagnostics_from_ssr_linters( default_disabled: !linter.is_enabled(), }; if conditions.enabled(config, is_generated, is_test) { - res.extend(linter.diagnostics(sema, file_id)); + let mut diagnostics = linter.diagnostics(sema, file_id); + + // Apply severity overrides for SSR linters + if let Some(lint_config) = config.lint_config.as_ref() { + for diagnostic in &mut diagnostics { + if let Some(override_severity) = + lint_config.get_severity_override(&linter.id()) + { + diagnostic.severity = override_severity; + } + } + } + + res.extend(diagnostics); } } } @@ -3041,4 +3115,41 @@ baz(1)->4. ); } } + + #[test] + fn test_linter_severity_override() { + let mut lint_config = LintConfig::default(); + lint_config.linters.insert( + DiagnosticCode::NoGarbageCollect, + LinterConfig { + severity: Some(Severity::Error), + }, + ); + + let config = DiagnosticsConfig::default() + .configure_diagnostics( + &lint_config, + &Some("no_garbage_collect".to_string()), + &None, + FallBackToAll::No, + ) + .unwrap(); + check_diagnostics_with_config( + config, + r#" + //- /src/main.erl + -module(main). + -export([error/0]). + + -spec error() -> ok. + error() -> + erlang:garbage_collect(). + %% ^^^^^^^^^^^^^^^^^^^^^^ 💡 error: Avoid forcing garbage collection. + //- /opt/lib/stdlib-3.17/src/erlang.erl otp_app:/opt/lib/stdlib-3.17 + -module(erlang). + -export([garbage_collect/0]). + garbage_collect() -> ok. + "#, + ); + } } From 04ec06feb0f1ac268017dfef7bb577a3c87b1926 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 2 Sep 2025 04:45:26 -0700 Subject: [PATCH 002/314] Be able to override a linter's include_test property via config Summary: Similarly to D81443678, make the `include_tests` property also configurable. The duplication between `diagnostics_from_function_call_linters` and `diagnostics_from_ssr_linters` will be addressed separately. Reviewed By: TD5 Differential Revision: D81449612 fbshipit-source-id: 2c40afd9a4c70092be5a2476f5afffee06789d40 --- crates/ide/src/diagnostics.rs | 66 +++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 5b80b95535..76ae6a7e62 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -857,6 +857,11 @@ impl LintConfig { pub fn get_severity_override(&self, diagnostic_code: &DiagnosticCode) -> Option { self.linters.get(diagnostic_code)?.severity } + + /// Get the include_tests override for a linter based on its diagnostic code + pub fn get_include_tests_override(&self, diagnostic_code: &DiagnosticCode) -> Option { + self.linters.get(diagnostic_code)?.include_tests + } } // --------------------------------------------------------------------- @@ -880,6 +885,7 @@ pub struct LintConfig { #[derive(Default)] pub struct LinterConfig { pub severity: Option, + pub include_tests: Option, } impl<'de> Deserialize<'de> for Severity { @@ -1213,10 +1219,19 @@ fn diagnostics_from_function_call_linters( let mut specs = Vec::new(); for linter in linters { if linter.should_process_file_id(sema, file_id) { + // Check if there is an include_tests override in the config + let include_tests = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_include_tests_override(&linter.id()) + .unwrap_or_else(|| linter.should_process_test_files()) + } else { + linter.should_process_test_files() + }; + let conditions = DiagnosticConditions { experimental: linter.is_experimental(), include_generated: linter.should_process_generated_files(), - include_tests: linter.should_process_test_files(), + include_tests, default_disabled: !linter.is_enabled(), }; if conditions.enabled(config, is_generated, is_test) { @@ -1264,10 +1279,19 @@ fn diagnostics_from_ssr_linters( for linter in linters { if linter.should_process_file_id(sema, file_id) { + // Check if there is an include_tests override in the config + let include_tests = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_include_tests_override(&linter.id()) + .unwrap_or_else(|| linter.should_process_test_files()) + } else { + linter.should_process_test_files() + }; + let conditions = DiagnosticConditions { experimental: linter.is_experimental(), include_generated: linter.should_process_generated_files(), - include_tests: linter.should_process_test_files(), + include_tests, default_disabled: !linter.is_enabled(), }; if conditions.enabled(config, is_generated, is_test) { @@ -3123,6 +3147,7 @@ baz(1)->4. DiagnosticCode::NoGarbageCollect, LinterConfig { severity: Some(Severity::Error), + include_tests: None, }, ); @@ -3152,4 +3177,41 @@ baz(1)->4. "#, ); } + + #[test] + fn test_linter_include_tests_override() { + let mut lint_config = LintConfig::default(); + lint_config.linters.insert( + DiagnosticCode::NoGarbageCollect, + LinterConfig { + severity: None, + include_tests: Some(true), + }, + ); + + let config = DiagnosticsConfig::default() + .configure_diagnostics( + &lint_config, + &Some("no_garbage_collect".to_string()), + &None, + FallBackToAll::No, + ) + .unwrap(); + check_diagnostics_with_config( + config, + r#" + //- /test/main_SUITE.erl extra:test + -module(main_SUITE). + -export([warning/0]). + + warning() -> + erlang:garbage_collect(). + %% ^^^^^^^^^^^^^^^^^^^^^^ 💡 warning: Avoid forcing garbage collection. + //- /opt/lib/stdlib-3.17/src/erlang.erl otp_app:/opt/lib/stdlib-3.17 + -module(erlang). + -export([garbage_collect/0]). + garbage_collect() -> ok. + "#, + ); + } } From a92b4eea99166d6bd8fd9344ae39a19ec498e80d Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 2 Sep 2025 04:45:26 -0700 Subject: [PATCH 003/314] Be able to override a linter's include_generated property via config Summary: Similarly to the other properties. Reviewed By: TD5 Differential Revision: D81450512 fbshipit-source-id: 4002024a72d5e6af87583a2502d7f2edf861aaf2 --- crates/ide/src/diagnostics.rs | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 76ae6a7e62..ef1f95e514 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -862,6 +862,11 @@ impl LintConfig { pub fn get_include_tests_override(&self, diagnostic_code: &DiagnosticCode) -> Option { self.linters.get(diagnostic_code)?.include_tests } + + /// Get the include_generated override for a linter based on its diagnostic code + pub fn get_include_generated_override(&self, diagnostic_code: &DiagnosticCode) -> Option { + self.linters.get(diagnostic_code)?.include_generated + } } // --------------------------------------------------------------------- @@ -886,6 +891,7 @@ pub struct LintConfig { pub struct LinterConfig { pub severity: Option, pub include_tests: Option, + pub include_generated: Option, } impl<'de> Deserialize<'de> for Severity { @@ -1228,9 +1234,18 @@ fn diagnostics_from_function_call_linters( linter.should_process_test_files() }; + // Check if there is an include_generated override in the config + let include_generated = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_include_generated_override(&linter.id()) + .unwrap_or_else(|| linter.should_process_generated_files()) + } else { + linter.should_process_generated_files() + }; + let conditions = DiagnosticConditions { experimental: linter.is_experimental(), - include_generated: linter.should_process_generated_files(), + include_generated, include_tests, default_disabled: !linter.is_enabled(), }; @@ -1288,9 +1303,18 @@ fn diagnostics_from_ssr_linters( linter.should_process_test_files() }; + // Check if there is an include_generated override in the config + let include_generated = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_include_generated_override(&linter.id()) + .unwrap_or_else(|| linter.should_process_generated_files()) + } else { + linter.should_process_generated_files() + }; + let conditions = DiagnosticConditions { experimental: linter.is_experimental(), - include_generated: linter.should_process_generated_files(), + include_generated, include_tests, default_disabled: !linter.is_enabled(), }; @@ -3148,6 +3172,7 @@ baz(1)->4. LinterConfig { severity: Some(Severity::Error), include_tests: None, + include_generated: None, }, ); @@ -3186,6 +3211,7 @@ baz(1)->4. LinterConfig { severity: None, include_tests: Some(true), + include_generated: None, }, ); @@ -3214,4 +3240,47 @@ baz(1)->4. "#, ); } + + #[test] + fn test_linter_include_generated_override() { + let mut lint_config = LintConfig::default(); + lint_config.linters.insert( + DiagnosticCode::NoGarbageCollect, + LinterConfig { + severity: None, + include_tests: None, + include_generated: Some(true), + }, + ); + + let config = DiagnosticsConfig::default() + .configure_diagnostics( + &lint_config, + &Some("no_garbage_collect".to_string()), + &None, + FallBackToAll::No, + ) + .unwrap() + .set_include_generated(true); + check_diagnostics_with_config( + config, + &format!( + r#" + //- /src/main.erl + % @{} + -module(main). + -export([warning/0]). + + warning() -> + erlang:garbage_collect(). + %% ^^^^^^^^^^^^^^^^^^^^^^ 💡 warning: Avoid forcing garbage collection. + //- /opt/lib/stdlib-3.17/src/erlang.erl otp_app:/opt/lib/stdlib-3.17 + -module(erlang). + -export([garbage_collect/0]). + garbage_collect() -> ok. + "#, + "generated" // Separate string, to avoid to mark this module itself as generated + ), + ); + } } From 6dbb64aa35ddede43d2e9362d8b0a34e65634417 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 2 Sep 2025 04:45:26 -0700 Subject: [PATCH 004/314] Be able to override a linter's experimental property via config Summary: Be able to override the `experimental` property of a linter via config. Reviewed By: TD5 Differential Revision: D81450739 fbshipit-source-id: c18b449249d48fc089709d17a5d6b1c1560db6f3 --- crates/ide/src/diagnostics.rs | 79 +++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index ef1f95e514..a3756dd8c3 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -867,6 +867,11 @@ impl LintConfig { pub fn get_include_generated_override(&self, diagnostic_code: &DiagnosticCode) -> Option { self.linters.get(diagnostic_code)?.include_generated } + + /// Get the experimental override for a linter based on its diagnostic code + pub fn get_experimental_override(&self, diagnostic_code: &DiagnosticCode) -> Option { + self.linters.get(diagnostic_code)?.experimental + } } // --------------------------------------------------------------------- @@ -892,6 +897,7 @@ pub struct LinterConfig { pub severity: Option, pub include_tests: Option, pub include_generated: Option, + pub experimental: Option, } impl<'de> Deserialize<'de> for Severity { @@ -1243,8 +1249,17 @@ fn diagnostics_from_function_call_linters( linter.should_process_generated_files() }; + // Check if there is an experimental override in the config + let experimental = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_experimental_override(&linter.id()) + .unwrap_or_else(|| linter.is_experimental()) + } else { + linter.is_experimental() + }; + let conditions = DiagnosticConditions { - experimental: linter.is_experimental(), + experimental, include_generated, include_tests, default_disabled: !linter.is_enabled(), @@ -1312,8 +1327,17 @@ fn diagnostics_from_ssr_linters( linter.should_process_generated_files() }; + // Check if there is an experimental override in the config + let experimental = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_experimental_override(&linter.id()) + .unwrap_or_else(|| linter.is_experimental()) + } else { + linter.is_experimental() + }; + let conditions = DiagnosticConditions { - experimental: linter.is_experimental(), + experimental, include_generated, include_tests, default_disabled: !linter.is_enabled(), @@ -3173,6 +3197,7 @@ baz(1)->4. severity: Some(Severity::Error), include_tests: None, include_generated: None, + experimental: None, }, ); @@ -3212,6 +3237,7 @@ baz(1)->4. severity: None, include_tests: Some(true), include_generated: None, + experimental: None, }, ); @@ -3250,6 +3276,7 @@ baz(1)->4. severity: None, include_tests: None, include_generated: Some(true), + experimental: None, }, ); @@ -3264,11 +3291,9 @@ baz(1)->4. .set_include_generated(true); check_diagnostics_with_config( config, - &format!( - r#" - //- /src/main.erl - % @{} - -module(main). + r#" + //- /src/main_generated.erl extra:generated + -module(main_generated). -export([warning/0]). warning() -> @@ -3278,6 +3303,46 @@ baz(1)->4. -module(erlang). -export([garbage_collect/0]). garbage_collect() -> ok. + "#, + ); + } + + #[test] + fn test_linter_experimental_override() { + let mut lint_config = LintConfig::default(); + lint_config.linters.insert( + DiagnosticCode::NoGarbageCollect, + LinterConfig { + severity: None, + include_tests: None, + include_generated: None, + experimental: Some(true), + }, + ); + + let config = DiagnosticsConfig::default() + .configure_diagnostics( + &lint_config, + &Some("no_garbage_collect".to_string()), + &None, + FallBackToAll::No, + ) + .unwrap(); + check_diagnostics_with_config( + config, + &format!( + r#" + //- /src/main.erl + % @{} + -module(main). + -export([warning/0]). + + warning() -> + erlang:garbage_collect(). + //- /opt/lib/stdlib-3.17/src/erlang.erl otp_app:/opt/lib/stdlib-3.17 + -module(erlang). + -export([garbage_collect/0]). + garbage_collect() -> ok. "#, "generated" // Separate string, to avoid to mark this module itself as generated ), From 2d8c4a03cfcf91aafcde317a9b445e886f774c3a Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 2 Sep 2025 04:45:26 -0700 Subject: [PATCH 005/314] Avoid duplication to extract linter conditions Summary: Start removing the duplication for handling different types of linters. Here we extract the conditions calculations into a helper function. This will be further simplified later. Reviewed By: TD5 Differential Revision: D81474341 fbshipit-source-id: b69796d6d36533e48c2ae3fff0783613be3535d6 --- crates/ide/src/diagnostics.rs | 98 ++++++++++++----------------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index a3756dd8c3..6f47fff98d 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -537,6 +537,36 @@ pub(crate) trait Linter { } } +fn conditions(linter: &dyn Linter, config: &DiagnosticsConfig) -> DiagnosticConditions { + let include_tests = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_include_tests_override(&linter.id()) + .unwrap_or_else(|| linter.should_process_test_files()) + } else { + linter.should_process_test_files() + }; + let include_generated = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_include_generated_override(&linter.id()) + .unwrap_or_else(|| linter.should_process_generated_files()) + } else { + linter.should_process_generated_files() + }; + let experimental = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_experimental_override(&linter.id()) + .unwrap_or_else(|| linter.is_experimental()) + } else { + linter.is_experimental() + }; + DiagnosticConditions { + experimental, + include_generated, + include_tests, + default_disabled: !linter.is_enabled(), + } +} + // A trait that simplifies writing linters matching function calls pub(crate) trait FunctionCallLinter: Linter { // Specify the list of functions the linter should emit issues for @@ -1231,39 +1261,7 @@ fn diagnostics_from_function_call_linters( let mut specs = Vec::new(); for linter in linters { if linter.should_process_file_id(sema, file_id) { - // Check if there is an include_tests override in the config - let include_tests = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_include_tests_override(&linter.id()) - .unwrap_or_else(|| linter.should_process_test_files()) - } else { - linter.should_process_test_files() - }; - - // Check if there is an include_generated override in the config - let include_generated = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_include_generated_override(&linter.id()) - .unwrap_or_else(|| linter.should_process_generated_files()) - } else { - linter.should_process_generated_files() - }; - - // Check if there is an experimental override in the config - let experimental = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_experimental_override(&linter.id()) - .unwrap_or_else(|| linter.is_experimental()) - } else { - linter.is_experimental() - }; - - let conditions = DiagnosticConditions { - experimental, - include_generated, - include_tests, - default_disabled: !linter.is_enabled(), - }; + let conditions = conditions(linter, config); if conditions.enabled(config, is_generated, is_test) { // Check if there is a severity override in the config let severity = if let Some(lint_config) = config.lint_config.as_ref() { @@ -1309,39 +1307,7 @@ fn diagnostics_from_ssr_linters( for linter in linters { if linter.should_process_file_id(sema, file_id) { - // Check if there is an include_tests override in the config - let include_tests = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_include_tests_override(&linter.id()) - .unwrap_or_else(|| linter.should_process_test_files()) - } else { - linter.should_process_test_files() - }; - - // Check if there is an include_generated override in the config - let include_generated = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_include_generated_override(&linter.id()) - .unwrap_or_else(|| linter.should_process_generated_files()) - } else { - linter.should_process_generated_files() - }; - - // Check if there is an experimental override in the config - let experimental = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_experimental_override(&linter.id()) - .unwrap_or_else(|| linter.is_experimental()) - } else { - linter.is_experimental() - }; - - let conditions = DiagnosticConditions { - experimental, - include_generated, - include_tests, - default_disabled: !linter.is_enabled(), - }; + let conditions = conditions(linter, config); if conditions.enabled(config, is_generated, is_test) { let mut diagnostics = linter.diagnostics(sema, file_id); From be360da10e31d1f0ec17644f04b00c2110940727 Mon Sep 17 00:00:00 2001 From: Tom Davies Date: Wed, 3 Sep 2025 04:10:11 -0700 Subject: [PATCH 006/314] Fix wording in W0030 diagnostic docs (dedicated `maps:put/3` syntax) Summary: Fixes broken wording in the docs. Created from CodeHub with https://fburl.com/edit-in-codehub Reviewed By: jcpetruzza Differential Revision: D81518020 fbshipit-source-id: d34f487ff1a21e07d32560f732c69bc77f5fe715 --- website/docs/erlang-error-index/w/W0030.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/erlang-error-index/w/W0030.md b/website/docs/erlang-error-index/w/W0030.md index 252e7169b9..cc8f981d1b 100644 --- a/website/docs/erlang-error-index/w/W0030.md +++ b/website/docs/erlang-error-index/w/W0030.md @@ -24,7 +24,7 @@ the map update syntax directly. If the keys are constants known at compile-time, using the map update syntax with the `=>` operator is more efficient than multiple calls to `maps:put/3`, especially for small maps. This implies than using the `=>` operator should -making future additions to the code also both performant and clear. +make future additions to the code both more performant and more clear. To fix the issue, use the built-in `Map#{Key => Value}` syntax to insert the `Key` and `Value` into the `Map`: From 43614330fa2fc25c513da1091f72b75fe2ea9853 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 3 Sep 2025 10:07:47 -0700 Subject: [PATCH 007/314] Avoid duplication in treating function / ssr linters Summary: Merge the processing of FunctionCallLinters / SSrLinters into a single function, avoid code duplication. Reviewed By: TD5 Differential Revision: D81474340 fbshipit-source-id: 87df4ed02248bf16c8fe0bfc537fb9b05cdd44a3 --- crates/ide/src/diagnostics.rs | 173 ++++++++++++++++------------------ 1 file changed, 80 insertions(+), 93 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 6f47fff98d..f939d73af2 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -537,7 +537,12 @@ pub(crate) trait Linter { } } -fn conditions(linter: &dyn Linter, config: &DiagnosticsConfig) -> DiagnosticConditions { +fn should_run( + linter: &dyn Linter, + config: &DiagnosticsConfig, + is_generated: bool, + is_test: bool, +) -> bool { let include_tests = if let Some(lint_config) = config.lint_config.as_ref() { lint_config .get_include_tests_override(&linter.id()) @@ -559,12 +564,13 @@ fn conditions(linter: &dyn Linter, config: &DiagnosticsConfig) -> DiagnosticCond } else { linter.is_experimental() }; - DiagnosticConditions { + let conditions = DiagnosticConditions { experimental, include_generated, include_tests, default_disabled: !linter.is_enabled(), - } + }; + conditions.enabled(config, is_generated, is_test) } // A trait that simplifies writing linters matching function calls @@ -634,11 +640,11 @@ pub(crate) trait SsrPatternsLinter: Linter { // we define a blanket implementation for all the methods using the `Context``, // to keep the code generic while allowing individual linters to specify their own context type. pub(crate) trait SsrCheckPatterns: Linter { - fn diagnostics(&self, sema: &Semantic, file_id: FileId) -> Vec; + fn diagnostics(&self, sema: &Semantic, file_id: FileId, severity: Severity) -> Vec; } impl SsrCheckPatterns for T { - fn diagnostics(&self, sema: &Semantic, file_id: FileId) -> Vec { + fn diagnostics(&self, sema: &Semantic, file_id: FileId, severity: Severity) -> Vec { let mut res = Vec::new(); for (pattern, context) in self.patterns() { let matches = match_pattern_in_file_functions(sema, self.strategy(), file_id, &pattern); @@ -650,7 +656,7 @@ impl SsrCheckPatterns for T { let mut d = Diagnostic::new(self.id(), message, matched.range.range) .with_fixes(fixes) .add_categories(categories) - .with_severity(self.severity()); + .with_severity(severity); if self.can_be_suppressed() { d = d.with_ignore_fix(sema, file_id); } @@ -1106,14 +1112,7 @@ pub fn native_diagnostics( config, &diagnostics_descriptors(), ); - diagnostics_from_function_call_linters( - &mut res, - &sema, - file_id, - config, - function_call_linters(), - ); - diagnostics_from_ssr_linters(&mut res, &sema, file_id, config, ssr_linters()); + diagnostics_from_linters(&mut res, &sema, file_id, config, linters()); let parse_diagnostics = parse.errors().iter().take(128).map(|err| { let (code, message) = match err { @@ -1224,33 +1223,47 @@ pub fn diagnostics_from_descriptors( }); } -/// Registry for function call linters that enables single AST traversal -pub(crate) fn function_call_linters() -> Vec<&'static dyn FunctionCallLinter> { - let mut linters: Vec<&'static dyn FunctionCallLinter> = vec![ - &sets_version_2::LINTER, - &no_garbage_collect::LINTER, - &no_size::LINTER, - &no_error_logger::LINTER, +/// Enum to represent either type of linter for unified processing +pub(crate) enum DiagnosticLinter { + FunctionCall(&'static dyn FunctionCallLinter), + SsrPatterns(&'static dyn SsrCheckPatterns), +} + +impl DiagnosticLinter { + fn as_linter(&self) -> &dyn Linter { + match self { + DiagnosticLinter::FunctionCall(linter) => *linter, + DiagnosticLinter::SsrPatterns(linter) => *linter, + } + } +} + +/// Unified registry for all types of linters +pub(crate) fn linters() -> Vec { + let mut all_linters = vec![ + // Function call linters + DiagnosticLinter::FunctionCall(&sets_version_2::LINTER), + DiagnosticLinter::FunctionCall(&no_garbage_collect::LINTER), + DiagnosticLinter::FunctionCall(&no_size::LINTER), + DiagnosticLinter::FunctionCall(&no_error_logger::LINTER), + // SSR linters + DiagnosticLinter::SsrPatterns(&unnecessary_fold_to_build_map::LINTER), + DiagnosticLinter::SsrPatterns(&binary_string_to_sigil::LINTER), + DiagnosticLinter::SsrPatterns(&unnecessary_map_to_list_in_comprehension::LINTER), ]; + + // Add meta-only linters // @fb-only - linters + + all_linters } -/// Registry for SSR-based linters -pub(crate) fn ssr_linters() -> Vec<&'static dyn SsrCheckPatterns> { - vec![ - &unnecessary_fold_to_build_map::LINTER, - &binary_string_to_sigil::LINTER, - &unnecessary_map_to_list_in_comprehension::LINTER, - ] -} - -fn diagnostics_from_function_call_linters( +fn diagnostics_from_linters( res: &mut Vec, sema: &Semantic, file_id: FileId, config: &DiagnosticsConfig, - linters: Vec<&'static dyn FunctionCallLinter>, + linters: Vec, ) { let is_generated = sema.db.is_generated(file_id); let is_test = sema @@ -1259,75 +1272,49 @@ fn diagnostics_from_function_call_linters( .unwrap_or(false); let mut specs = Vec::new(); - for linter in linters { - if linter.should_process_file_id(sema, file_id) { - let conditions = conditions(linter, config); - if conditions.enabled(config, is_generated, is_test) { - // Check if there is a severity override in the config - let severity = if let Some(lint_config) = config.lint_config.as_ref() { - lint_config - .get_severity_override(&linter.id()) - .unwrap_or_else(|| linter.severity()) - } else { - linter.severity() - }; - let diagnostic_template = DiagnosticTemplate { - code: linter.id(), - message: linter.description(), - severity, - with_ignore_fix: linter.can_be_suppressed(), - use_range: UseRange::NameOnly, - }; - let spec = vec![FunctionCallDiagnostic { - diagnostic_template, - matches: linter.matches_functions(), - }]; - specs.extend(spec); + for l in linters { + let linter = l.as_linter(); + let code = linter.id(); + if linter.should_process_file_id(sema, file_id) + && should_run(linter, config, is_generated, is_test) + { + let severity = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_severity_override(&linter.id()) + .unwrap_or_else(|| linter.severity()) + } else { + linter.severity() + }; + match l { + DiagnosticLinter::FunctionCall(function_linter) => { + let diagnostic_template = DiagnosticTemplate { + code, + message: linter.description(), + severity, + with_ignore_fix: linter.can_be_suppressed(), + use_range: UseRange::NameOnly, + }; + let spec = vec![FunctionCallDiagnostic { + diagnostic_template, + matches: function_linter.matches_functions(), + }]; + specs.extend(spec); + } + DiagnosticLinter::SsrPatterns(ssr_linter) => { + let diagnostics = ssr_linter.diagnostics(sema, file_id, severity); + res.extend(diagnostics); + } } } } + + // Handle function call linters specs if !specs.is_empty() { check_used_functions(sema, file_id, &specs, res); } } -fn diagnostics_from_ssr_linters( - res: &mut Vec, - sema: &Semantic, - file_id: FileId, - config: &DiagnosticsConfig, - linters: Vec<&'static dyn SsrCheckPatterns>, -) { - let is_generated = sema.db.is_generated(file_id); - let is_test = sema - .db - .is_test_suite_or_test_helper(file_id) - .unwrap_or(false); - - for linter in linters { - if linter.should_process_file_id(sema, file_id) { - let conditions = conditions(linter, config); - if conditions.enabled(config, is_generated, is_test) { - let mut diagnostics = linter.diagnostics(sema, file_id); - - // Apply severity overrides for SSR linters - if let Some(lint_config) = config.lint_config.as_ref() { - for diagnostic in &mut diagnostics { - if let Some(override_severity) = - lint_config.get_severity_override(&linter.id()) - { - diagnostic.severity = override_severity; - } - } - } - - res.extend(diagnostics); - } - } - } -} - fn label_syntax_errors( source_file: &SourceFile, diagnostics: impl Iterator, From 2d6afacd9271e2c69ecfd36bb3c541cad3f8facb Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 3 Sep 2025 10:07:47 -0700 Subject: [PATCH 008/314] Allowing specifying a custom cli_severity/override Summary: The property will be later used by some of the linters (e.g `debugging_functions`). Reviewed By: TD5 Differential Revision: D81481377 fbshipit-source-id: d1cf925f63dea74a2ef42b1bed8b13de27b32e97 --- crates/ide/src/diagnostics.rs | 34 +++++++++++++++++++++++---- crates/ide/src/diagnostics/helpers.rs | 5 +++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index f939d73af2..15d1a8e241 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -506,6 +506,11 @@ pub(crate) trait Linter { Severity::Warning } + // For CLI, when using the --use-cli-severity flag. It defaults to `severity()` + fn cli_severity(&self) -> Severity { + self.severity() + } + // Specify if the linter issues can be suppressed via a `% elp:ignore` comment. fn can_be_suppressed(&self) -> bool { true @@ -640,11 +645,23 @@ pub(crate) trait SsrPatternsLinter: Linter { // we define a blanket implementation for all the methods using the `Context``, // to keep the code generic while allowing individual linters to specify their own context type. pub(crate) trait SsrCheckPatterns: Linter { - fn diagnostics(&self, sema: &Semantic, file_id: FileId, severity: Severity) -> Vec; + fn diagnostics( + &self, + sema: &Semantic, + file_id: FileId, + severity: Severity, + cli_severity: Severity, + ) -> Vec; } impl SsrCheckPatterns for T { - fn diagnostics(&self, sema: &Semantic, file_id: FileId, severity: Severity) -> Vec { + fn diagnostics( + &self, + sema: &Semantic, + file_id: FileId, + severity: Severity, + cli_severity: Severity, + ) -> Vec { let mut res = Vec::new(); for (pattern, context) in self.patterns() { let matches = match_pattern_in_file_functions(sema, self.strategy(), file_id, &pattern); @@ -656,7 +673,8 @@ impl SsrCheckPatterns for T { let mut d = Diagnostic::new(self.id(), message, matched.range.range) .with_fixes(fixes) .add_categories(categories) - .with_severity(severity); + .with_severity(severity) + .with_cli_severity(cli_severity); if self.can_be_suppressed() { d = d.with_ignore_fix(sema, file_id); } @@ -1286,12 +1304,20 @@ fn diagnostics_from_linters( } else { linter.severity() }; + let cli_severity = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_severity_override(&linter.id()) + .unwrap_or_else(|| linter.cli_severity()) + } else { + linter.cli_severity() + }; match l { DiagnosticLinter::FunctionCall(function_linter) => { let diagnostic_template = DiagnosticTemplate { code, message: linter.description(), severity, + cli_severity, with_ignore_fix: linter.can_be_suppressed(), use_range: UseRange::NameOnly, }; @@ -1302,7 +1328,7 @@ fn diagnostics_from_linters( specs.extend(spec); } DiagnosticLinter::SsrPatterns(ssr_linter) => { - let diagnostics = ssr_linter.diagnostics(sema, file_id, severity); + let diagnostics = ssr_linter.diagnostics(sema, file_id, severity, cli_severity); res.extend(diagnostics); } } diff --git a/crates/ide/src/diagnostics/helpers.rs b/crates/ide/src/diagnostics/helpers.rs index ebfdd79ad1..566410fd53 100644 --- a/crates/ide/src/diagnostics/helpers.rs +++ b/crates/ide/src/diagnostics/helpers.rs @@ -29,6 +29,7 @@ pub(crate) struct DiagnosticTemplate { pub(crate) code: DiagnosticCode, pub(crate) message: String, pub(crate) severity: Severity, + pub(crate) cli_severity: Severity, pub(crate) with_ignore_fix: bool, pub(crate) use_range: UseRange, } @@ -77,7 +78,8 @@ pub(crate) fn check_function_with_diagnostic_template( let range = ctx.range(&extra.use_range); if range.file_id == def.file.file_id { let diag = Diagnostic::new(extra.code.clone(), extra.message.clone(), range.range) - .with_severity(extra.severity); + .with_severity(extra.severity) + .with_cli_severity(extra.cli_severity); let diag = if extra.with_ignore_fix { diag.with_ignore_fix(sema, def_fb.file_id()) } else { @@ -130,6 +132,7 @@ mod tests { code: DiagnosticCode::AdHoc("a code".to_string()), message: "diagnostic message".to_string(), severity: Severity::Warning, + cli_severity: Severity::Warning, with_ignore_fix: true, use_range: UseRange::WithArgs, }, From 2dc5420836a2e2a536fc5f6d22183205c2cbbdc0 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 3 Sep 2025 10:07:47 -0700 Subject: [PATCH 009/314] Support custom fixes in FunctionCallLinter trait Summary: Allow instances of the `FunctionCallLinter` trait to implement two new custom methods: * `is_valid_match` * `fixes` The first one, given the *call context* allows to validate each match and return a linter-specific context which is passed around to the other functions. The `fixes` method allows a linter to specify custom fixes. It receives the context produced by the `is_valid_match` method as part of the *match context*. The `debugging_function` is then converted to use the new approach, becoming much simpler. The templating mechanism from the `helpers` module is totally gone, and replaced by the new trait-based one. There's one performance implication: when the `FunctionCallLinter` trait was introduced, we started collecting "specs" and performing a fold only once. While this approach was convenient, it would have made implementing this new functionality a lot more complicated. Instead, I propose we sacrifice the extra folds (after all, that was state of the world before traits) for the time being. This brings the function call linters closer to the SSR-based ones. Then we can think of a different approach for folding/visiting. Reviewed By: jcpetruzza Differential Revision: D81516582 fbshipit-source-id: 6b9dd28b91eb272a670c5bf8679660c600c8611a --- crates/ide/src/diagnostics.rs | 125 +++++++++---- .../ide/src/diagnostics/debugging_function.rs | 168 +++++++----------- crates/ide/src/diagnostics/helpers.rs | 156 ---------------- crates/ide/src/diagnostics/no_error_logger.rs | 2 + .../ide/src/diagnostics/no_garbage_collect.rs | 2 + crates/ide/src/diagnostics/no_size.rs | 2 + crates/ide/src/diagnostics/sets_version_2.rs | 2 + 7 files changed, 163 insertions(+), 294 deletions(-) delete mode 100644 crates/ide/src/diagnostics/helpers.rs diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 15d1a8e241..adeaae41a7 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -79,11 +79,11 @@ use serde::Serialize; use crate::FunctionMatch; use crate::RootDatabase; use crate::SourceDatabase; +use crate::codemod_helpers::CheckCallCtx; +use crate::codemod_helpers::MatchCtx; use crate::codemod_helpers::UseRange; +use crate::codemod_helpers::find_call_in_function; use crate::common_test; -use crate::diagnostics::helpers::DiagnosticTemplate; -use crate::diagnostics::helpers::FunctionCallDiagnostic; -use crate::diagnostics::helpers::check_used_functions; mod application_env; mod atoms_exhaustion; @@ -101,7 +101,6 @@ mod eqwalizer_assists; mod expression_can_be_simplified; mod from_config; mod head_mismatch; -mod helpers; mod inefficient_enumerate; mod inefficient_flatlength; mod inefficient_last; @@ -580,10 +579,87 @@ fn should_run( // A trait that simplifies writing linters matching function calls pub(crate) trait FunctionCallLinter: Linter { + /// Associated type - each linter defines its own + type Context: Clone + fmt::Debug + PartialEq + Default; + // Specify the list of functions the linter should emit issues for fn matches_functions(&self) -> Vec { vec![] } + + // Custom check for the function call. Returning None for a given call skips processing. + // By default all calls are included. + // The callback returns a function that can be used in subsequent callbacks. + fn is_match_valid(&self, _check_call_context: &CheckCallCtx<'_, ()>) -> Option { + Some(Self::Context::default()) + } + + /// Return an optional vector of quick-fixes + fn fixes( + &self, + _match_context: &MatchCtx, + _sema: &Semantic, + _file_id: FileId, + ) -> Option> { + None + } +} + +// Instances of the FunctionCallLinter trait can specify a custom `Context` type, +// which is passed around in callbacks. +// To be able to keep a registry of all linters we define a blanket implementation for all the methods using the `Context`, +// to keep the code generic while allowing individual linters to specify their own context type. +pub(crate) trait FunctionCallDiagnostics: Linter { + fn diagnostics( + &self, + sema: &Semantic, + file_id: FileId, + severity: Severity, + cli_severity: Severity, + ) -> Vec; +} + +impl FunctionCallDiagnostics for T { + fn diagnostics( + &self, + sema: &Semantic, + file_id: FileId, + severity: Severity, + cli_severity: Severity, + ) -> Vec { + let mut diagnostics = Vec::new(); + let matches = self.matches_functions(); + let mfas: Vec<(&FunctionMatch, ())> = matches.iter().map(|m| (m, ())).collect(); + sema.def_map_local(file_id) + .get_functions() + .for_each(|(_, def)| { + find_call_in_function( + &mut diagnostics, + sema, + def, + &mfas, + &move |ctx| self.is_match_valid(&ctx), + &move |ctx @ MatchCtx { sema, def_fb, .. }| { + let range = ctx.range(&UseRange::NameOnly); + if range.file_id == def.file.file_id { + let fixes = self.fixes(&ctx, sema, file_id); + let mut diag = + Diagnostic::new(self.id(), self.description(), range.range) + .with_fixes(fixes) + .with_severity(severity) + .with_cli_severity(cli_severity); + if self.can_be_suppressed() { + diag = diag.with_ignore_fix(sema, def_fb.file_id()); + }; + Some(diag) + } else { + None + } + }, + ); + }); + diagnostics + } } /// A trait that simplifies writing linters using SSR patterns @@ -639,12 +715,11 @@ pub(crate) trait SsrPatternsLinter: Linter { } } -// Instances of the SsrCheckPatternsLinter trait can specify a custom `Context` type, -// which is passed around in callbacks.\ -// To be able to keep a registry of all linters in the `ssr_linters` function, -// we define a blanket implementation for all the methods using the `Context``, +// Instances of the SsrPatternsLinter trait can specify a custom `Context` type, +// which is passed around in callbacks. +// To be able to keep a registry of all linters we define a blanket implementation for all the methods using the `Context`, // to keep the code generic while allowing individual linters to specify their own context type. -pub(crate) trait SsrCheckPatterns: Linter { +pub(crate) trait SsrPatternsDiagnostics: Linter { fn diagnostics( &self, sema: &Semantic, @@ -654,7 +729,7 @@ pub(crate) trait SsrCheckPatterns: Linter { ) -> Vec; } -impl SsrCheckPatterns for T { +impl SsrPatternsDiagnostics for T { fn diagnostics( &self, sema: &Semantic, @@ -1200,7 +1275,6 @@ pub fn diagnostics_descriptors<'a>() -> Vec<&'a DiagnosticDescriptor<'a>> { &edoc::DESCRIPTOR, ¯o_precedence_suprise::DESCRIPTOR, &undocumented_function::DESCRIPTOR, - &debugging_function::DESCRIPTOR, &duplicate_module::DESCRIPTOR, &undocumented_module::DESCRIPTOR, &no_dialyzer_attribute::DESCRIPTOR, @@ -1243,8 +1317,8 @@ pub fn diagnostics_from_descriptors( /// Enum to represent either type of linter for unified processing pub(crate) enum DiagnosticLinter { - FunctionCall(&'static dyn FunctionCallLinter), - SsrPatterns(&'static dyn SsrCheckPatterns), + FunctionCall(&'static dyn FunctionCallDiagnostics), + SsrPatterns(&'static dyn SsrPatternsDiagnostics), } impl DiagnosticLinter { @@ -1264,6 +1338,7 @@ pub(crate) fn linters() -> Vec { DiagnosticLinter::FunctionCall(&no_garbage_collect::LINTER), DiagnosticLinter::FunctionCall(&no_size::LINTER), DiagnosticLinter::FunctionCall(&no_error_logger::LINTER), + DiagnosticLinter::FunctionCall(&debugging_function::LINTER), // SSR linters DiagnosticLinter::SsrPatterns(&unnecessary_fold_to_build_map::LINTER), DiagnosticLinter::SsrPatterns(&binary_string_to_sigil::LINTER), @@ -1289,11 +1364,8 @@ fn diagnostics_from_linters( .is_test_suite_or_test_helper(file_id) .unwrap_or(false); - let mut specs = Vec::new(); - for l in linters { let linter = l.as_linter(); - let code = linter.id(); if linter.should_process_file_id(sema, file_id) && should_run(linter, config, is_generated, is_test) { @@ -1313,19 +1385,9 @@ fn diagnostics_from_linters( }; match l { DiagnosticLinter::FunctionCall(function_linter) => { - let diagnostic_template = DiagnosticTemplate { - code, - message: linter.description(), - severity, - cli_severity, - with_ignore_fix: linter.can_be_suppressed(), - use_range: UseRange::NameOnly, - }; - let spec = vec![FunctionCallDiagnostic { - diagnostic_template, - matches: function_linter.matches_functions(), - }]; - specs.extend(spec); + let diagnostics = + function_linter.diagnostics(sema, file_id, severity, cli_severity); + res.extend(diagnostics); } DiagnosticLinter::SsrPatterns(ssr_linter) => { let diagnostics = ssr_linter.diagnostics(sema, file_id, severity, cli_severity); @@ -1334,11 +1396,6 @@ fn diagnostics_from_linters( } } } - - // Handle function call linters specs - if !specs.is_empty() { - check_used_functions(sema, file_id, &specs, res); - } } fn label_syntax_errors( diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index a658ee9561..83cbc4efa3 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -10,129 +10,90 @@ use elp_ide_assists::Assist; use elp_ide_db::elp_base_db::FileId; -use elp_ide_db::elp_base_db::FileRange; use elp_ide_db::source_change::SourceChangeBuilder; use elp_text_edit::TextRange; -use hir::FunctionDef; use hir::Semantic; -use lazy_static::lazy_static; use crate::FunctionMatch; use crate::codemod_helpers::CheckCallCtx; use crate::codemod_helpers::MatchCtx; -use crate::codemod_helpers::find_call_in_function; use crate::codemod_helpers::statement_range; -use crate::diagnostics::Diagnostic; use crate::diagnostics::DiagnosticCode; -use crate::diagnostics::DiagnosticConditions; -use crate::diagnostics::DiagnosticDescriptor; +use crate::diagnostics::FunctionCallLinter; +use crate::diagnostics::Linter; use crate::diagnostics::Severity; +use crate::diagnostics::meta_only; // @fb-only -const DIAGNOSTIC_CODE: DiagnosticCode = DiagnosticCode::DebuggingFunction; -const DIAGNOSTIC_MESSAGE: &str = "Debugging functions should only be used during local debugging and usages should not be checked in."; -const DIAGNOSTIC_SEVERITY: Severity = Severity::WeakWarning; // For IDE -const DIAGNOSTIC_CLI_SEVERITY: Severity = Severity::Error; // For CLI, when using the --use-cli-severity flag -const REMOVE_FIX_ID: &str = "remove_invocation"; -const REMOVE_FIX_LABEL: &str = "Remove invocation"; +pub(crate) struct NoDebuggingFunctionLinter; -pub(crate) static DESCRIPTOR: DiagnosticDescriptor = DiagnosticDescriptor { - conditions: DiagnosticConditions { - experimental: false, - include_generated: true, - include_tests: true, - default_disabled: false, - }, - checker: &|diags, sema, file_id, _ext| { - debugging_functions(diags, sema, file_id); - }, -}; - -fn debugging_functions(diagnostics: &mut Vec, sema: &Semantic, file_id: FileId) { - lazy_static! { - static ref BAD_CALLS: Vec = - vec![FunctionMatch::m("redbug"),] - .into_iter() - // @fb-only - .collect(); - static ref BAD_CALLS_MFAS: Vec<(&'static FunctionMatch, ())> = BAD_CALLS - .iter() - .map(|matcher| (matcher, ())) - .collect::>(); +impl Linter for NoDebuggingFunctionLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::DebuggingFunction } - - sema.def_map_local(file_id) - .get_functions() - .for_each(|(_arity, def)| { - check_function(diagnostics, sema, def, &BAD_CALLS_MFAS, file_id); - }); -} - -fn check_function( - diags: &mut Vec, - sema: &Semantic, - def: &FunctionDef, - mfas: &[(&FunctionMatch, ())], - file_id: FileId, -) { - let source_file = sema.parse(file_id); - find_call_in_function( - diags, - sema, - def, - mfas, - &move |CheckCallCtx { parents, .. }: CheckCallCtx<'_, ()>| { - let call_expr_id = parents.last().cloned(); - Some(call_expr_id) - }, - &move |MatchCtx { - sema, - range_surface_mf, - def_fb, - extra, - .. - }| { - if let Some(hir::fold::ParentId::HirIdx(hir_idx)) = &extra { - if let Some(expr_id) = hir_idx.as_expr_id() { - let body_map = def_fb.get_body_map(); - let in_file_ast_ptr = body_map.expr(expr_id)?; - let expr_ast = in_file_ast_ptr.to_node(&source_file)?; - let range = statement_range(&expr_ast); - make_diagnostic(sema, def.file.file_id, range_surface_mf, range) - } else { - None - } - } else { - None - } - }, - ); -} - -fn make_diagnostic( - sema: &Semantic, - file_id: FileId, - range_mf_only: Option, - range: TextRange, -) -> Option { - let range_mf_only = range_mf_only?; - if range_mf_only.file_id == file_id { - let diagnostic = Diagnostic::new(DIAGNOSTIC_CODE, DIAGNOSTIC_MESSAGE, range_mf_only.range) - .with_severity(DIAGNOSTIC_SEVERITY) - .with_cli_severity(DIAGNOSTIC_CLI_SEVERITY) - .with_fixes(Some(vec![remove_fix(file_id, range)])) - .with_ignore_fix(sema, file_id); - Some(diagnostic) - } else { - None + fn description(&self) -> String { + "Debugging functions should only be used during local debugging and usages should not be checked in.".to_string() + } + fn severity(&self) -> Severity { + Severity::WeakWarning + } + fn cli_severity(&self) -> Severity { + Severity::Error + } + fn should_process_generated_files(&self) -> bool { + true } } +impl FunctionCallLinter for NoDebuggingFunctionLinter { + type Context = Option; + + fn matches_functions(&self) -> Vec { + lazy_function_matches![ + vec![FunctionMatch::m("redbug")] + .into_iter() + // @fb-only + .collect::>() + ] + } + + fn is_match_valid(&self, context: &CheckCallCtx<'_, ()>) -> Option { + let call_expr_id = context.parents.last().cloned(); + Some(call_expr_id) + } + + fn fixes( + &self, + match_context: &MatchCtx, + sema: &Semantic, + file_id: FileId, + ) -> Option> { + let source_file = sema.parse(file_id); + if let Some(hir::fold::ParentId::HirIdx(hir_idx)) = &match_context.extra { + let expr_id = hir_idx.as_expr_id()?; + let body_map = match_context.def_fb.get_body_map(); + let in_file_ast_ptr = body_map.expr(expr_id)?; + let expr_ast = in_file_ast_ptr.to_node(&source_file)?; + let range = statement_range(&expr_ast); + Some(vec![remove_fix(file_id, range)]) + } else { + None + } + } +} + +pub static LINTER: NoDebuggingFunctionLinter = NoDebuggingFunctionLinter; + fn remove_fix(file_id: FileId, range: TextRange) -> Assist { let mut builder = SourceChangeBuilder::new(file_id); builder.delete(range); let source_change = builder.finish(); - crate::fix(REMOVE_FIX_ID, REMOVE_FIX_LABEL, source_change, range) + crate::fix( + "remove_invocation", + "Remove invocation", + source_change, + range, + ) } #[cfg(test)] @@ -140,7 +101,6 @@ mod tests { use expect_test::expect; - use crate::diagnostics::debugging_function::REMOVE_FIX_LABEL; use crate::tests; #[test] @@ -223,7 +183,7 @@ main() -> #[test] fn test_redbug_remove_fix() { tests::check_specific_fix( - REMOVE_FIX_LABEL, + "Remove invocation", r#" //- /src/main.erl -module(main). diff --git a/crates/ide/src/diagnostics/helpers.rs b/crates/ide/src/diagnostics/helpers.rs deleted file mode 100644 index 566410fd53..0000000000 --- a/crates/ide/src/diagnostics/helpers.rs +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is dual-licensed under either the MIT license found in the - * LICENSE-MIT file in the root directory of this source tree or the Apache - * License, Version 2.0 found in the LICENSE-APACHE file in the root directory - * of this source tree. You may select, at your option, one of the - * above-listed licenses. - */ - -//! Helpers for writing diagnostics - -use elp_ide_db::DiagnosticCode; -use elp_ide_db::elp_base_db::FileId; -use hir::FunctionDef; -use hir::Semantic; - -use super::Diagnostic; -use super::Severity; -use crate::FunctionMatch; -use crate::codemod_helpers::MatchCtx; -use crate::codemod_helpers::UseRange; -use crate::codemod_helpers::find_call_in_function; - -// --------------------------------------------------------------------- - -#[derive(Debug)] -pub(crate) struct DiagnosticTemplate { - pub(crate) code: DiagnosticCode, - pub(crate) message: String, - pub(crate) severity: Severity, - pub(crate) cli_severity: Severity, - pub(crate) with_ignore_fix: bool, - pub(crate) use_range: UseRange, -} - -/// Define a checker for a function that should not be used. Generate -/// a diagnostic according to the template if it is found. -#[derive(Debug)] -pub(crate) struct FunctionCallDiagnostic { - pub(crate) diagnostic_template: DiagnosticTemplate, - pub(crate) matches: Vec, -} - -pub(crate) fn check_used_functions( - sema: &Semantic, - file_id: FileId, - used_functions: &[FunctionCallDiagnostic], - diags: &mut Vec, -) { - let mfas: Vec<(&FunctionMatch, &DiagnosticTemplate)> = used_functions - .iter() - .flat_map(|u| u.matches.iter().map(|m| (m, &u.diagnostic_template))) - .collect(); - sema.def_map_local(file_id) - .get_functions() - .for_each(|(_, def)| check_function_with_diagnostic_template(diags, sema, def, &mfas)); -} - -pub(crate) fn check_function_with_diagnostic_template( - diags: &mut Vec, - sema: &Semantic, - def: &FunctionDef, - mfas: &[(&FunctionMatch, &DiagnosticTemplate)], -) { - find_call_in_function( - diags, - sema, - def, - mfas, - &move |ctx| Some(*ctx.t), - &move |ctx @ MatchCtx { - sema, - def_fb, - extra, - .. - }: MatchCtx<'_, &DiagnosticTemplate>| { - let range = ctx.range(&extra.use_range); - if range.file_id == def.file.file_id { - let diag = Diagnostic::new(extra.code.clone(), extra.message.clone(), range.range) - .with_severity(extra.severity) - .with_cli_severity(extra.cli_severity); - let diag = if extra.with_ignore_fix { - diag.with_ignore_fix(sema, def_fb.file_id()) - } else { - diag - }; - Some(diag) - } else { - None - } - }, - ); -} - -// --------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use elp_ide_db::DiagnosticCode; - - use super::DiagnosticTemplate; - use super::FunctionCallDiagnostic; - use super::check_used_functions; - use crate::FunctionMatch; - use crate::codemod_helpers::UseRange; - use crate::diagnostics::AdhocSemanticDiagnostics; - use crate::diagnostics::DiagnosticsConfig; - use crate::diagnostics::Severity; - use crate::tests::check_diagnostics_with_config_and_ad_hoc; - - #[track_caller] - pub(crate) fn check_diagnostics_with_ad_hoc_semantics( - ad_hoc_semantic_diagnostics: Vec<&dyn AdhocSemanticDiagnostics>, - fixture: &str, - ) { - let config = DiagnosticsConfig::default() - .set_experimental(true) - .disable(DiagnosticCode::UndefinedFunction); - check_diagnostics_with_config_and_ad_hoc(config, &ad_hoc_semantic_diagnostics, fixture) - } - - #[test] - fn unused_function() { - check_diagnostics_with_ad_hoc_semantics( - vec![&|acc, sema, file_id, _ext| { - check_used_functions( - sema, - file_id, - &[FunctionCallDiagnostic { - diagnostic_template: DiagnosticTemplate { - code: DiagnosticCode::AdHoc("a code".to_string()), - message: "diagnostic message".to_string(), - severity: Severity::Warning, - cli_severity: Severity::Warning, - with_ignore_fix: true, - use_range: UseRange::WithArgs, - }, - matches: vec![FunctionMatch::mfas("main", "foo", vec![0])] - .into_iter() - .flatten() - .collect(), - }], - acc, - ); - }], - r#" - -module(main). - -export([foo/0]). - foo() -> main:foo(). - %% ^^^^^^^^^^ 💡 warning: diagnostic message - - "#, - ) - } -} diff --git a/crates/ide/src/diagnostics/no_error_logger.rs b/crates/ide/src/diagnostics/no_error_logger.rs index c0f348c387..d1a0da221a 100644 --- a/crates/ide/src/diagnostics/no_error_logger.rs +++ b/crates/ide/src/diagnostics/no_error_logger.rs @@ -29,6 +29,8 @@ impl Linter for NoErrorLoggerLinter { } impl FunctionCallLinter for NoErrorLoggerLinter { + type Context = (); + fn matches_functions(&self) -> Vec { crate::lazy_function_matches![vec![FunctionMatch::m("error_logger")]] } diff --git a/crates/ide/src/diagnostics/no_garbage_collect.rs b/crates/ide/src/diagnostics/no_garbage_collect.rs index f9eed8e6d6..af2d74e3ee 100644 --- a/crates/ide/src/diagnostics/no_garbage_collect.rs +++ b/crates/ide/src/diagnostics/no_garbage_collect.rs @@ -29,6 +29,8 @@ impl Linter for NoGarbageCollectLinter { } impl FunctionCallLinter for NoGarbageCollectLinter { + type Context = (); + fn matches_functions(&self) -> Vec { lazy_function_matches![vec![FunctionMatch::mf("erlang", "garbage_collect")]] } diff --git a/crates/ide/src/diagnostics/no_size.rs b/crates/ide/src/diagnostics/no_size.rs index c89d8f3404..5524a3655f 100644 --- a/crates/ide/src/diagnostics/no_size.rs +++ b/crates/ide/src/diagnostics/no_size.rs @@ -29,6 +29,8 @@ impl Linter for NoSizeLinter { } impl FunctionCallLinter for NoSizeLinter { + type Context = (); + fn matches_functions(&self) -> Vec { lazy_function_matches![vec![FunctionMatch::mfa("erlang", "size", 1)]] } diff --git a/crates/ide/src/diagnostics/sets_version_2.rs b/crates/ide/src/diagnostics/sets_version_2.rs index ccbd95db9e..44a228d717 100644 --- a/crates/ide/src/diagnostics/sets_version_2.rs +++ b/crates/ide/src/diagnostics/sets_version_2.rs @@ -29,6 +29,8 @@ impl Linter for SetsVersion2Linter { } impl FunctionCallLinter for SetsVersion2Linter { + type Context = (); + fn matches_functions(&self) -> Vec { lazy_function_matches![ FunctionMatch::mfas("sets", "new", vec![0]), From 26c8c3058a468e82b6d55bf408bb958749f9a5b0 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 3 Sep 2025 10:07:47 -0700 Subject: [PATCH 010/314] Convert atoms exhaustion linter to use the FunctionCallLinter trait Summary: Convert the `atoms_exhaustion` linter to use the `Linter` trait. To enable this, ensures `sema` is available in the `is_match_valid` method. Reviewed By: jcpetruzza Differential Revision: D81589208 fbshipit-source-id: a41788a0d5979737550749b007f3d7b2d9413b3f --- crates/ide/src/diagnostics.rs | 10 +- .../ide/src/diagnostics/atoms_exhaustion.rs | 156 +++++++----------- .../ide/src/diagnostics/debugging_function.rs | 6 +- 3 files changed, 75 insertions(+), 97 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index adeaae41a7..8e6c641def 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -590,7 +590,11 @@ pub(crate) trait FunctionCallLinter: Linter { // Custom check for the function call. Returning None for a given call skips processing. // By default all calls are included. // The callback returns a function that can be used in subsequent callbacks. - fn is_match_valid(&self, _check_call_context: &CheckCallCtx<'_, ()>) -> Option { + fn is_match_valid( + &self, + _check_call_context: &CheckCallCtx<'_, ()>, + _sema: &Semantic, + ) -> Option { Some(Self::Context::default()) } @@ -638,7 +642,7 @@ impl FunctionCallDiagnostics for T { sema, def, &mfas, - &move |ctx| self.is_match_valid(&ctx), + &move |ctx| self.is_match_valid(&ctx, sema), &move |ctx @ MatchCtx { sema, def_fb, .. }| { let range = ctx.range(&UseRange::NameOnly); if range.file_id == def.file.file_id { @@ -1268,7 +1272,6 @@ pub fn diagnostics_descriptors<'a>() -> Vec<&'a DiagnosticDescriptor<'a>> { &head_mismatch::DESCRIPTOR_SEMANTIC, &missing_separator::DESCRIPTOR, &cross_node_eval::DESCRIPTOR, - &atoms_exhaustion::DESCRIPTOR, &boolean_precedence::DESCRIPTOR, &record_tuple_match::DESCRIPTOR, &unspecific_include::DESCRIPTOR, @@ -1339,6 +1342,7 @@ pub(crate) fn linters() -> Vec { DiagnosticLinter::FunctionCall(&no_size::LINTER), DiagnosticLinter::FunctionCall(&no_error_logger::LINTER), DiagnosticLinter::FunctionCall(&debugging_function::LINTER), + DiagnosticLinter::FunctionCall(&atoms_exhaustion::LINTER), // SSR linters DiagnosticLinter::SsrPatterns(&unnecessary_fold_to_build_map::LINTER), DiagnosticLinter::SsrPatterns(&binary_string_to_sigil::LINTER), diff --git a/crates/ide/src/diagnostics/atoms_exhaustion.rs b/crates/ide/src/diagnostics/atoms_exhaustion.rs index b83ad2a387..8249eef757 100644 --- a/crates/ide/src/diagnostics/atoms_exhaustion.rs +++ b/crates/ide/src/diagnostics/atoms_exhaustion.rs @@ -9,112 +9,82 @@ */ use elp_ide_db::elp_base_db::FileId; -use elp_text_edit::TextRange; -use hir::FunctionDef; use hir::Semantic; -use lazy_static::lazy_static; -use super::DiagnosticConditions; -use super::DiagnosticDescriptor; use crate::FunctionMatch; -use crate::codemod_helpers::MatchCtx; -use crate::codemod_helpers::find_call_in_function; +use crate::codemod_helpers::CheckCallCtx; // @fb-only -use crate::diagnostics::Diagnostic; use crate::diagnostics::DiagnosticCode; -use crate::diagnostics::Severity; +use crate::diagnostics::FunctionCallLinter; +use crate::diagnostics::Linter; +use crate::lazy_function_matches; -pub(crate) static DESCRIPTOR: DiagnosticDescriptor = DiagnosticDescriptor { - conditions: DiagnosticConditions { - experimental: false, - include_generated: true, - include_tests: false, - default_disabled: false, - }, - checker: &|diags, sema, file_id, _ext| { - atoms_exhaustion(diags, sema, file_id); - }, -}; +pub(crate) struct AtomsExhaustionLinter; -fn atoms_exhaustion(diagnostics: &mut Vec, sema: &Semantic, file_id: FileId) { - lazy_static! { - static ref BAD_CALLS: Vec = vec![ - FunctionMatch::mfa("erlang", "binary_to_atom", 1), - FunctionMatch::mfa("erlang", "binary_to_atom", 2), - FunctionMatch::mfa("erlang", "list_to_atom", 1), - // T187850479: Make it configurable - // FunctionMatch::mfa("erlang", "binary_to_term", 1), - // FunctionMatch::mfa("erlang", "binary_to_term", 2), - ] - .into_iter() +impl Linter for AtomsExhaustionLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::AtomsExhaustion + } + fn description(&self) -> String { + "Risk of atoms exhaustion.".to_string() + } + fn should_process_generated_files(&self) -> bool { + true + } + fn should_process_test_files(&self) -> bool { + false + } + fn should_process_file_id(&self, sema: &Semantic, file_id: FileId) -> bool { // @fb-only - .collect(); + true // @oss-only + } +} - static ref BAD_CALLS_MFAS: Vec<(&'static FunctionMatch, ())> = BAD_CALLS - .iter() - .map(|matcher| (matcher, ())) - .collect::>(); +impl FunctionCallLinter for AtomsExhaustionLinter { + type Context = (); + + fn matches_functions(&self) -> Vec { + lazy_function_matches![ + vec![ + FunctionMatch::mfa("erlang", "binary_to_atom", 1), + FunctionMatch::mfa("erlang", "binary_to_atom", 2), + FunctionMatch::mfa("erlang", "list_to_atom", 1), + // T187850479: Make it configurable + // FunctionMatch::mfa("erlang", "binary_to_term", 1), + // FunctionMatch::mfa("erlang", "binary_to_term", 2), + ] + .into_iter() + // @fb-only + .collect::>() + ] } - sema.def_map_local(file_id) - .get_functions() - .for_each(|(_arity, def)| { + fn is_match_valid( + &self, + context: &CheckCallCtx<'_, ()>, + sema: &Semantic, + ) -> Option { + // @fb-only // @fb-only - let is_relevant = true; // @oss-only - if is_relevant { - check_function(diagnostics, sema, def, &BAD_CALLS_MFAS); - } - }); -} - -fn check_function( - diags: &mut Vec, - sema: &Semantic, - def: &FunctionDef, - mfas: &[(&FunctionMatch, ())], -) { - #[rustfmt::skip] - find_call_in_function( - diags, - sema, - def, - mfas, - &move |ctx| { - // @fb-only - // @fb-only - let is_safe = false; // @oss-only - if !is_safe { - match ctx.args.as_vec()[..] { - [_, options] => { - let body = ctx.in_clause.body(); - match &body[options].literal_list_contains_atom(ctx.in_clause, "safe") { - Some(true) => None, - _ => Some(("".to_string(), "".to_string())), - } + let is_safe = false; // @oss-only + if !is_safe { + match context.args.as_vec()[..] { + [_, options] => { + let body = context.in_clause.body(); + match &body[options].literal_list_contains_atom(context.in_clause, "safe") { + Some(true) => None, + _ => Some(()), } - _ => Some(("".to_string(), "".to_string())), } - } else { - None + _ => Some(()), } - }, - &move |MatchCtx { sema, range, .. }| { - if range.file_id == def.file.file_id { - let diag = make_diagnostic(sema, def.file.file_id, range.range); - Some(diag) - } else { - None - } - }, - ); + } else { + None + } + } } -fn make_diagnostic(sema: &Semantic, file_id: FileId, range: TextRange) -> Diagnostic { - let message = "Risk of atoms exhaustion.".to_string(); - Diagnostic::new(DiagnosticCode::AtomsExhaustion, message, range) - .with_severity(Severity::Warning) - .with_ignore_fix(sema, file_id) -} +pub static LINTER: AtomsExhaustionLinter = AtomsExhaustionLinter; #[cfg(test)] mod tests { @@ -130,9 +100,9 @@ mod tests { -export([main/0]). main() -> erlang:list_to_atom(foo), -%% ^^^^^^^^^^^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. +%% ^^^^^^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. list_to_atom(foo). -%% ^^^^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. +%% ^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. //- /opt/lib/stdlib-3.17/src/erlang.erl otp_app:/opt/lib/stdlib-3.17 -module(erlang). @@ -151,9 +121,9 @@ mod tests { -export([main/0]). main() -> erlang:binary_to_atom(foo), -%% ^^^^^^^^^^^^^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. +%% ^^^^^^^^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. binary_to_atom(foo). -%% ^^^^^^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. +%% ^^^^^^^^^^^^^^ 💡 warning: Risk of atoms exhaustion. //- /opt/lib/stdlib-3.17/src/erlang.erl otp_app:/opt/lib/stdlib-3.17 -module(erlang). diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index 83cbc4efa3..6f72cc4f2d 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -57,7 +57,11 @@ impl FunctionCallLinter for NoDebuggingFunctionLinter { ] } - fn is_match_valid(&self, context: &CheckCallCtx<'_, ()>) -> Option { + fn is_match_valid( + &self, + context: &CheckCallCtx<'_, ()>, + _sema: &Semantic, + ) -> Option { let call_expr_id = context.parents.last().cloned(); Some(call_expr_id) } From 3caae9249caa5e3daf69ae8eba60d3125a52ab13 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Thu, 4 Sep 2025 01:04:33 -0700 Subject: [PATCH 011/314] Refactor definition of linters Summary: Clearer definition of linters. Reviewed By: jcpetruzza, TD5 Differential Revision: D81589873 fbshipit-source-id: b8ee681fce7dd834759eedde69e1b893f03c0052 --- crates/ide/src/diagnostics.rs | 45 +++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 8e6c641def..5e65ca5f45 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -1333,21 +1333,40 @@ impl DiagnosticLinter { } } +/// Function call linters that detect issues in function calls +const FUNCTION_CALL_LINTERS: &[&dyn FunctionCallDiagnostics] = &[ + &sets_version_2::LINTER, + &no_garbage_collect::LINTER, + &no_size::LINTER, + &no_error_logger::LINTER, + &debugging_function::LINTER, + &atoms_exhaustion::LINTER, +]; + +/// SSR pattern linters that use structural search and replace patterns +const SSR_PATTERN_LINTERS: &[&dyn SsrPatternsDiagnostics] = &[ + &unnecessary_fold_to_build_map::LINTER, + &binary_string_to_sigil::LINTER, + &unnecessary_map_to_list_in_comprehension::LINTER, +]; + /// Unified registry for all types of linters pub(crate) fn linters() -> Vec { - let mut all_linters = vec![ - // Function call linters - DiagnosticLinter::FunctionCall(&sets_version_2::LINTER), - DiagnosticLinter::FunctionCall(&no_garbage_collect::LINTER), - DiagnosticLinter::FunctionCall(&no_size::LINTER), - DiagnosticLinter::FunctionCall(&no_error_logger::LINTER), - DiagnosticLinter::FunctionCall(&debugging_function::LINTER), - DiagnosticLinter::FunctionCall(&atoms_exhaustion::LINTER), - // SSR linters - DiagnosticLinter::SsrPatterns(&unnecessary_fold_to_build_map::LINTER), - DiagnosticLinter::SsrPatterns(&binary_string_to_sigil::LINTER), - DiagnosticLinter::SsrPatterns(&unnecessary_map_to_list_in_comprehension::LINTER), - ]; + let mut all_linters = Vec::new(); + + // Add function call linters + all_linters.extend( + FUNCTION_CALL_LINTERS + .iter() + .map(|linter| DiagnosticLinter::FunctionCall(*linter)), + ); + + // Add SSR pattern linters + all_linters.extend( + SSR_PATTERN_LINTERS + .iter() + .map(|linter| DiagnosticLinter::SsrPatterns(*linter)), + ); // Add meta-only linters // @fb-only From 6faa9f865ad5b65a8c52745cac61f6f5fb44aa7b Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Fri, 5 Sep 2025 05:20:33 -0700 Subject: [PATCH 012/314] Ignore formatting to avoid fb_only/oss_only inconsistency Summary: To avoid a GitHub CI failure as in: https://github.com/WhatsApp/erlang-language-platform/actions/runs/17457515576/job/49574340083 Reviewed By: alanz Differential Revision: D81774626 fbshipit-source-id: a452985ed3d1f2022b627130f75a481e0521db59 --- crates/ide/src/diagnostics/atoms_exhaustion.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ide/src/diagnostics/atoms_exhaustion.rs b/crates/ide/src/diagnostics/atoms_exhaustion.rs index 8249eef757..334bf86853 100644 --- a/crates/ide/src/diagnostics/atoms_exhaustion.rs +++ b/crates/ide/src/diagnostics/atoms_exhaustion.rs @@ -64,6 +64,7 @@ impl FunctionCallLinter for AtomsExhaustionLinter { context: &CheckCallCtx<'_, ()>, sema: &Semantic, ) -> Option { + #[rustfmt::skip] // @fb-only // @fb-only let is_safe = false; // @oss-only From 1897fe15bf50bc7e8844976613e114722ddaef4c Mon Sep 17 00:00:00 2001 From: "Brian Suh (WhatsApp)" Date: Fri, 5 Sep 2025 09:56:07 -0700 Subject: [PATCH 013/314] add links to logview Summary: add logview link into the macro doc Reviewed By: robertoaloi Differential Revision: D81513245 fbshipit-source-id: 37768bd67e9f6cdca774343c849a51a33dee1ede --- crates/elp/src/bin/glean.rs | 46 +++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/crates/elp/src/bin/glean.rs b/crates/elp/src/bin/glean.rs index f9566cb870..da9bcf1b53 100644 --- a/crates/elp/src/bin/glean.rs +++ b/crates/elp/src/bin/glean.rs @@ -8,6 +8,7 @@ * above-listed licenses. */ +use core::option::Option::None; use std::io::Write; use std::mem; use std::path::Path; @@ -300,6 +301,8 @@ pub(crate) struct MacroTarget { expansion: Option, #[serde(skip_serializing_if = "Option::is_none")] ods_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + logview_url: Option, } #[derive(Serialize, Debug)] @@ -928,12 +931,22 @@ impl GleanIndexer { let range = def.source(db).syntax().text_range(); let text = &db.file_text(id)[range]; let text = format!("```erlang\n{text}\n```"); - let doc = match (&x.key.expansion, &x.key.ods_url) { - (None, None) => text, - (None, Some(o)) => format!("[ODS]({o})\n{text}"), - (Some(e), None) => format!("{text}\n---\n\n{e}"), - (Some(e), Some(o)) => format!("[ODS]({o})\n{text}\n---\n\n{e}"), - }; + let doc = format!( + "{}{}{}{}", + x.key + .ods_url + .as_ref() + .map_or(String::new(), |o| format!("[ODS]({})\n", o)), + x.key + .logview_url + .as_ref() + .map_or(String::new(), |l| format!("[LogView]({})\n", l)), + text, + x.key + .expansion + .as_ref() + .map_or(String::new(), |e| format!("\n---\n\n{}", e)) + ); let decl = Declaration::MacroDeclaration( MacroDecl { name: x.key.name.clone(), @@ -1619,6 +1632,7 @@ impl GleanIndexer { arity: define.name.arity(), expansion, ods_url: None, + logview_url: None, }; Some(XRef { source: range.into(), @@ -2486,6 +2500,19 @@ mod tests { // @fb-only } + #[test] + fn xref_macro_logview_v2_test() { + let spec = r#" + //- /src/macro.erl + -module(macro). + -define(LOG_E(X), (fun() -> wa_log:send_if(X) end)()). + baz(atom) -> ?LOG_E("test"), + %% ^^^^^ macro.erl/macro/LOG_E/97/has_logview/fun () -> 'wa_log':'send_if'( "test" ) end() + + "#; + // @fb-only + } + #[test] fn xref_macro_in_pat_v2_test() { let spec = r#" @@ -2818,9 +2845,10 @@ mod tests { Some(arity) => arity.to_string(), None => "no_arity".to_string(), }; - let ods_link = match &xref.key.ods_url { - Some(_) => "has_ods", - None => "no_ods", + let ods_link = match (&xref.key.ods_url, &xref.key.logview_url) { + (Some(_), _) => "has_ods", + (None, Some(_)) => "has_logview", + (None, None) => "no_ods", }; let exp = match &xref.key.expansion { Some(exp) => exp From d55260aaa4b69773a5a6b0680e297ccb5d984ad8 Mon Sep 17 00:00:00 2001 From: "Brian Suh (WhatsApp)" Date: Fri, 5 Sep 2025 09:56:07 -0700 Subject: [PATCH 014/314] add links to wam browser Summary: add wam browser link into the record doc Reviewed By: robertoaloi Differential Revision: D81522678 fbshipit-source-id: 115f64f117b46eabe7131a3936e673ad6c32a30e --- crates/elp/src/bin/glean.rs | 83 ++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/crates/elp/src/bin/glean.rs b/crates/elp/src/bin/glean.rs index da9bcf1b53..3662dd4006 100644 --- a/crates/elp/src/bin/glean.rs +++ b/crates/elp/src/bin/glean.rs @@ -317,6 +317,8 @@ pub(crate) struct RecordTarget { #[serde(rename = "file")] file_id: GleanFileId, name: String, + #[serde(skip_serializing_if = "Option::is_none")] + wam_url: Option, } #[derive(Serialize, Debug)] @@ -969,6 +971,38 @@ impl GleanIndexer { x.key.arity = Some(xref.source.start); } } + XRefTarget::Record(x) => { + // Add WAM documentation for records that have wam_url + if let Some(wam_url) = &x.key.wam_url { + let id: FileId = x.key.file_id.clone().into(); + let def_map = db.def_map(id); + let record_name = Name::from_erlang_service(&x.key.name); + if let Some(def) = def_map.get_record(&record_name) { + let range = def.source(db).syntax().text_range(); + let text = &db.file_text(id)[range]; + let text = format!("```erlang\n{text}\n```"); + let doc = format!("[WAM]({})\n{}", wam_url, text); + let decl = Declaration::RecordDeclaration( + RecordDecl { + name: x.key.name.clone(), + span: xref.source.clone(), + } + .into(), + ); + let doc_decl = Declaration::DocDeclaration( + DocDecl { + target: Box::new(decl.clone()), + span: xref.source.clone(), + text: doc, + } + .into(), + ); + file_decl.declarations.push(decl); + file_decl.declarations.push(doc_decl); + x.key.file_id = file_id.into(); + } + } + } _ => (), } } @@ -1725,12 +1759,19 @@ impl GleanIndexer { let (_, _, expr_source) = ctx.body_with_expr_source(sema)?; let source_file = sema.parse(file_id); let range = Self::find_range(sema, ctx, &source_file, &expr_source)?; + + // Check if this is a WAM event record and build link + use elp_ide::meta_only::wam_links; + let wam_ctx = wam_links::WamEventCtx::new(sema.db.upcast()); + let wam_url = wam_ctx.build_wam_link(name).map(|link| link.url()); + Some(XRef { source: range.into(), target: XRefTarget::Record( RecordTarget { file_id: def.file.file_id.into(), name: def.record.name.to_string(), + wam_url, } .into(), ), @@ -2288,7 +2329,7 @@ mod tests { }). baz(A) -> #query{ size = A }. - %% ^^^^^^ glean_module9.erl/rec/query + %% ^^^^^^ glean_module9.erl/rec/query/no_wam "#; xref_v2_check(spec); @@ -2318,9 +2359,9 @@ mod tests { -record(stats, {count, time}). baz(Time) -> [{#stats.count, 1}, - %% ^^^^^^ glean_module10.erl/rec/stats + %% ^^^^^^ glean_module10.erl/rec/stats/no_wam {#stats.time, Time}]. - %% ^^^^^^ glean_module10.erl/rec/stats + %% ^^^^^^ glean_module10.erl/rec/stats/no_wam "#; @@ -2349,7 +2390,7 @@ mod tests { -record(stats, {count, time}). baz(Stats) -> Stats#stats.count. - %% ^^^^^^ glean_module11.erl/rec/stats + %% ^^^^^^ glean_module11.erl/rec/stats/no_wam "#; xref_v2_check(spec); @@ -2377,7 +2418,7 @@ mod tests { -record(stats, {count, time}). baz(Stats, NewCnt) -> Stats#stats{count = NewCnt}. - %% ^^^^^^ glean_module12.erl/rec/stats + %% ^^^^^^ glean_module12.erl/rec/stats/no_wam "#; xref_v2_check(spec); @@ -2407,7 +2448,7 @@ mod tests { -record(stats, {count, time}). baz(Stats) -> #stats{count = Count, time = Time} = Stats. - %% ^^^^^^ glean_module13.erl/rec/stats + %% ^^^^^^ glean_module13.erl/rec/stats/no_wam "#; xref_v2_check(spec); @@ -2431,7 +2472,7 @@ mod tests { //- /glean/app_glean/src/glean_module14.erl -record(rec, {field}). foo(#rec.field) -> ok. - %% ^^^^ glean_module14.erl/rec/rec + %% ^^^^ glean_module14.erl/rec/rec/no_wam "#; xref_v2_check(spec); @@ -2457,15 +2498,31 @@ mod tests { //- /glean/app_glean/src/glean_module15.erl -record(stats, {count, time}). -spec baz() -> #stats{}. - %% ^^^^^^ glean_module15.erl/rec/stats + %% ^^^^^^ glean_module15.erl/rec/stats/no_wam baz() -> #stats{count = 1, time = 2}. - %% ^^^^^^ glean_module15.erl/rec/stats + %% ^^^^^^ glean_module15.erl/rec/stats/no_wam "#; xref_v2_check(spec); } + #[test] + fn xref_wam_record_v2_test() { + let spec = r#" + //- /src/record.erl + -module(record). + -record(wam_event_chatd_c2s_logout, {data, reason}). + + baz(Data) -> + Event = #wam_event_chatd_c2s_logout{data = Data}, + %% ^^^^^^^^^^^^^^^^^^^^^^^^^^^ record.erl/rec/wam_event_chatd_c2s_logout/has_wam + Event. + + "#; + // @fb-only + } + #[test] fn xref_macro_v2_test() { let spec = r#" @@ -2866,7 +2923,13 @@ mod tests { ) } XRefTarget::Header(_) => f.write_str("header"), - XRefTarget::Record(xref) => f.write_str(format!("rec/{}", xref.key.name).as_str()), + XRefTarget::Record(xref) => { + let wam_link = match &xref.key.wam_url { + Some(_) => "has_wam", + None => "no_wam", + }; + f.write_str(format!("rec/{}/{}", xref.key.name, wam_link).as_str()) + } XRefTarget::Type(xref) => { f.write_str(format!("type/{}/{}", xref.key.name, xref.key.arity).as_str()) } From b1f429ad86be97bececbfc3da0ba170114e1c036 Mon Sep 17 00:00:00 2001 From: "Brian Suh (WhatsApp)" Date: Fri, 5 Sep 2025 09:56:07 -0700 Subject: [PATCH 015/314] add links to scuba Summary: add scuba links to macro doc Reviewed By: robertoaloi Differential Revision: D81545729 fbshipit-source-id: 8b87e01ebfbc715de33cab0a60f2f93c41244915 --- crates/elp/src/bin/glean.rs | 52 +++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/elp/src/bin/glean.rs b/crates/elp/src/bin/glean.rs index 3662dd4006..64612b65c3 100644 --- a/crates/elp/src/bin/glean.rs +++ b/crates/elp/src/bin/glean.rs @@ -303,6 +303,8 @@ pub(crate) struct MacroTarget { ods_url: Option, #[serde(skip_serializing_if = "Option::is_none")] logview_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scuba_urls: Option>, } #[derive(Serialize, Debug)] @@ -933,8 +935,28 @@ impl GleanIndexer { let range = def.source(db).syntax().text_range(); let text = &db.file_text(id)[range]; let text = format!("```erlang\n{text}\n```"); + let scuba_links = + x.key + .scuba_urls + .as_ref() + .map_or(String::new(), |scuba_urls| { + scuba_urls + .iter() + .map(|(display_name, url)| { + format!("[{}]({})", display_name, url) + }) + .collect::>() + .join(" | ") + }); + + let scuba_section = if !scuba_links.is_empty() { + format!("Scuba: {}\n", scuba_links) + } else { + String::new() + }; + let doc = format!( - "{}{}{}{}", + "{}{}{}{}{}", x.key .ods_url .as_ref() @@ -943,6 +965,7 @@ impl GleanIndexer { .logview_url .as_ref() .map_or(String::new(), |l| format!("[LogView]({})\n", l)), + scuba_section, text, x.key .expansion @@ -1667,6 +1690,7 @@ impl GleanIndexer { expansion, ods_url: None, logview_url: None, + scuba_urls: None, }; Some(XRef { source: range.into(), @@ -2570,6 +2594,19 @@ mod tests { // @fb-only } + #[test] + fn xref_macro_scuba_v2_test() { + let spec = r#" + //- /src/macro.erl + -module(macro). + -define(LOG_SCUBA(X), wa_scuba:log_tablename(pii, X)). + baz(atom) -> ?LOG_SCUBA(test_event), + %% ^^^^^^^^^ macro.erl/macro/LOG_SCUBA/97/has_scuba/'wa_scuba':'log_tablename'( 'pii', 'test_event' ) + + "#; + // @fb-only + } + #[test] fn xref_macro_in_pat_v2_test() { let spec = r#" @@ -2902,10 +2939,15 @@ mod tests { Some(arity) => arity.to_string(), None => "no_arity".to_string(), }; - let ods_link = match (&xref.key.ods_url, &xref.key.logview_url) { - (Some(_), _) => "has_ods", - (None, Some(_)) => "has_logview", - (None, None) => "no_ods", + let ods_link = match ( + &xref.key.ods_url, + &xref.key.logview_url, + &xref.key.scuba_urls, + ) { + (Some(_), _, _) => "has_ods", + (None, Some(_), _) => "has_logview", + (None, None, Some(_)) => "has_scuba", + (None, None, None) => "no_ods", }; let exp = match &xref.key.expansion { Some(exp) => exp From ab12249dacf074580d043c8b452bbd3ac2c8b9f5 Mon Sep 17 00:00:00 2001 From: "Brian Suh (WhatsApp)" Date: Fri, 5 Sep 2025 09:56:07 -0700 Subject: [PATCH 016/314] add gatekeeper for new links Reviewed By: robertoaloi Differential Revision: D81690172 fbshipit-source-id: cb3986344d75fbdf77350d240c502ad145b7db9d --- crates/elp/src/config.rs | 41 ++++++++++++++++++- .../src/resources/test/config_stanza.stdout | 15 +++++++ crates/elp/src/to_proto.rs | 10 ++++- crates/ide/src/annotations.rs | 11 +++++ crates/ide/src/lib.rs | 1 + 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/elp/src/config.rs b/crates/elp/src/config.rs index fab48abbe1..432382a0c0 100644 --- a/crates/elp/src/config.rs +++ b/crates/elp/src/config.rs @@ -89,6 +89,12 @@ config_data! { /// Whether to show the `Link` lenses. Only applies when /// `#elp.lens.enable#` is set. lens_links_enable: bool = json! { false }, + /// Whether to enable LogView lens links. + lens_logview_links: bool = json! { false }, + /// Whether to enable Scuba lens links. + lens_scuba_links: bool = json! { false }, + /// Whether to enable WAM lens links. + lens_wam_links: bool = json! { false }, /// Configure LSP-based logging using env_logger syntax. log: String = json! { "error" }, /// Whether to show Signature Help. @@ -128,6 +134,9 @@ pub struct LensConfig { pub buck2_mode: Option, pub debug: bool, pub links: bool, + pub logview_links: bool, + pub scuba_links: bool, + pub wam_links: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -323,6 +332,9 @@ impl Config { && self.data.lens_run_coverage_enable, debug: self.data.lens_enable && self.data.lens_debug_enable, links: self.data.lens_enable && self.data.lens_links_enable, + logview_links: self.data.lens_enable && self.data.lens_logview_links, + scuba_links: self.data.lens_enable && self.data.lens_scuba_links, + wam_links: self.data.lens_enable && self.data.lens_wam_links, } } @@ -375,6 +387,18 @@ impl Config { self.data.eqwalizer_all = value; } + pub fn set_lens_logview_links(&mut self, value: bool) { + self.data.lens_logview_links = value; + } + + pub fn set_lens_scuba_links(&mut self, value: bool) { + self.data.lens_scuba_links = value; + } + + pub fn set_lens_wam_links(&mut self, value: bool) { + self.data.lens_wam_links = value; + } + pub fn inlay_hints(&self) -> InlayHintsConfig { InlayHintsConfig { parameter_hints: self.data.inlayHints_parameterHints_enable, @@ -580,7 +604,7 @@ mod tests { let s = remove_ws(&schema); - expect![[r#""elp.diagnostics.disabled":{"default":[],"items":{"type":"string"},"markdownDescription":"ListofELPdiagnosticstodisable.","type":"array","uniqueItems":true},"elp.diagnostics.enableExperimental":{"default":false,"markdownDescription":"WhethertoshowexperimentalELPdiagnosticsthatmight\nhavemorefalsepositivesthanusual.","type":"boolean"},"elp.diagnostics.enableOtp":{"default":false,"markdownDescription":"WhethertoreportdiagnosticsforOTPfiles.","type":"boolean"},"elp.diagnostics.onSave.enable":{"default":false,"markdownDescription":"Updatenativediagnosticsonlywhenthefileissaved.","type":"boolean"},"elp.edoc.enable":{"default":false,"markdownDescription":"WhethertoreportEDocdiagnostics.","type":"boolean"},"elp.eqwalizer.all":{"default":false,"markdownDescription":"WhethertoreportEqwalizerdiagnosticsforthewholeprojectandnotonlyforopenedfiles.","type":"boolean"},"elp.eqwalizer.chunkSize":{"default":100,"markdownDescription":"Chunksizetouseforproject-wideeqwalization.","minimum":0,"type":"integer"},"elp.eqwalizer.maxTasks":{"default":32,"markdownDescription":"Maximumnumberoftaskstoruninparallelforproject-wideeqwalization.","minimum":0,"type":"integer"},"elp.highlightDynamic.enable":{"default":false,"markdownDescription":"Ifenabled,highlightvariableswithtype`dynamic()`whenEqwalizerresultsareavailable.","type":"boolean"},"elp.hoverActions.docLinks.enable":{"default":false,"markdownDescription":"WhethertoshowHoverActionsoftype`docs`.Onlyapplieswhen\n`#elp.hoverActions.enable#`isset.","type":"boolean"},"elp.hoverActions.enable":{"default":false,"markdownDescription":"WhethertoshowHoverActions.","type":"boolean"},"elp.inlayHints.parameterHints.enable":{"default":true,"markdownDescription":"Whethertoshowfunctionparameternameinlayhintsatthecall\nsite.","type":"boolean"},"elp.lens.buck2.mode":{"default":null,"markdownDescription":"Thebuck2modetouseforrunningtestsviathecodelenses.","type":["null","string"]},"elp.lens.debug.enable":{"default":false,"markdownDescription":"Whethertoshowthe`Debug`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.enable":{"default":false,"markdownDescription":"WhethertoshowCodeLensesinErlangfiles.","type":"boolean"},"elp.lens.links.enable":{"default":false,"markdownDescription":"Whethertoshowthe`Link`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.run.coverage.enable":{"default":true,"markdownDescription":"Displaycodecoverageinformationwhenrunningtestsviathe\nCodeLenses.Onlyapplieswhen`#elp.lens.enabled`and\n`#elp.lens.run.enable#`areset.","type":"boolean"},"elp.lens.run.enable":{"default":false,"markdownDescription":"Whethertoshowthe`Run`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.run.interactive.enable":{"default":false,"markdownDescription":"Whethertoshowthe`RunInteractive`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.log":{"default":"error","markdownDescription":"ConfigureLSP-basedloggingusingenv_loggersyntax.","type":"string"},"elp.signatureHelp.enable":{"default":true,"markdownDescription":"WhethertoshowSignatureHelp.","type":"boolean"},"elp.typesOnHover.enable":{"default":false,"markdownDescription":"Displaytypeswhenhoveringoverexpressions.","type":"boolean"},"#]] + expect![[r#""elp.diagnostics.disabled":{"default":[],"items":{"type":"string"},"markdownDescription":"ListofELPdiagnosticstodisable.","type":"array","uniqueItems":true},"elp.diagnostics.enableExperimental":{"default":false,"markdownDescription":"WhethertoshowexperimentalELPdiagnosticsthatmight\nhavemorefalsepositivesthanusual.","type":"boolean"},"elp.diagnostics.enableOtp":{"default":false,"markdownDescription":"WhethertoreportdiagnosticsforOTPfiles.","type":"boolean"},"elp.diagnostics.onSave.enable":{"default":false,"markdownDescription":"Updatenativediagnosticsonlywhenthefileissaved.","type":"boolean"},"elp.edoc.enable":{"default":false,"markdownDescription":"WhethertoreportEDocdiagnostics.","type":"boolean"},"elp.eqwalizer.all":{"default":false,"markdownDescription":"WhethertoreportEqwalizerdiagnosticsforthewholeprojectandnotonlyforopenedfiles.","type":"boolean"},"elp.eqwalizer.chunkSize":{"default":100,"markdownDescription":"Chunksizetouseforproject-wideeqwalization.","minimum":0,"type":"integer"},"elp.eqwalizer.maxTasks":{"default":32,"markdownDescription":"Maximumnumberoftaskstoruninparallelforproject-wideeqwalization.","minimum":0,"type":"integer"},"elp.highlightDynamic.enable":{"default":false,"markdownDescription":"Ifenabled,highlightvariableswithtype`dynamic()`whenEqwalizerresultsareavailable.","type":"boolean"},"elp.hoverActions.docLinks.enable":{"default":false,"markdownDescription":"WhethertoshowHoverActionsoftype`docs`.Onlyapplieswhen\n`#elp.hoverActions.enable#`isset.","type":"boolean"},"elp.hoverActions.enable":{"default":false,"markdownDescription":"WhethertoshowHoverActions.","type":"boolean"},"elp.inlayHints.parameterHints.enable":{"default":true,"markdownDescription":"Whethertoshowfunctionparameternameinlayhintsatthecall\nsite.","type":"boolean"},"elp.lens.buck2.mode":{"default":null,"markdownDescription":"Thebuck2modetouseforrunningtestsviathecodelenses.","type":["null","string"]},"elp.lens.debug.enable":{"default":false,"markdownDescription":"Whethertoshowthe`Debug`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.enable":{"default":false,"markdownDescription":"WhethertoshowCodeLensesinErlangfiles.","type":"boolean"},"elp.lens.links.enable":{"default":false,"markdownDescription":"Whethertoshowthe`Link`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.logview.links":{"default":false,"markdownDescription":"WhethertoenableLogViewlenslinks.","type":"boolean"},"elp.lens.run.coverage.enable":{"default":true,"markdownDescription":"Displaycodecoverageinformationwhenrunningtestsviathe\nCodeLenses.Onlyapplieswhen`#elp.lens.enabled`and\n`#elp.lens.run.enable#`areset.","type":"boolean"},"elp.lens.run.enable":{"default":false,"markdownDescription":"Whethertoshowthe`Run`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.run.interactive.enable":{"default":false,"markdownDescription":"Whethertoshowthe`RunInteractive`lenses.Onlyapplieswhen\n`#elp.lens.enable#`isset.","type":"boolean"},"elp.lens.scuba.links":{"default":false,"markdownDescription":"WhethertoenableScubalenslinks.","type":"boolean"},"elp.lens.wam.links":{"default":false,"markdownDescription":"WhethertoenableWAMlenslinks.","type":"boolean"},"elp.log":{"default":"error","markdownDescription":"ConfigureLSP-basedloggingusingenv_loggersyntax.","type":"string"},"elp.signatureHelp.enable":{"default":true,"markdownDescription":"WhethertoshowSignatureHelp.","type":"boolean"},"elp.typesOnHover.enable":{"default":false,"markdownDescription":"Displaytypeswhenhoveringoverexpressions.","type":"boolean"},"#]] .assert_eq(s.as_str()); expect![[r#" @@ -673,6 +697,11 @@ mod tests { "markdownDescription": "Whether to show the `Link` lenses. Only applies when\n`#elp.lens.enable#` is set.", "type": "boolean" }, + "elp.lens.logview.links": { + "default": false, + "markdownDescription": "Whether to enable LogView lens links.", + "type": "boolean" + }, "elp.lens.run.coverage.enable": { "default": true, "markdownDescription": "Display code coverage information when running tests via the\nCode Lenses. Only applies when `#elp.lens.enabled` and\n`#elp.lens.run.enable#` are set.", @@ -688,6 +717,16 @@ mod tests { "markdownDescription": "Whether to show the `Run Interactive` lenses. Only applies when\n`#elp.lens.enable#` is set.", "type": "boolean" }, + "elp.lens.scuba.links": { + "default": false, + "markdownDescription": "Whether to enable Scuba lens links.", + "type": "boolean" + }, + "elp.lens.wam.links": { + "default": false, + "markdownDescription": "Whether to enable WAM lens links.", + "type": "boolean" + }, "elp.log": { "default": "error", "markdownDescription": "Configure LSP-based logging using env_logger syntax.", diff --git a/crates/elp/src/resources/test/config_stanza.stdout b/crates/elp/src/resources/test/config_stanza.stdout index 09531aa223..bdd5c8b487 100644 --- a/crates/elp/src/resources/test/config_stanza.stdout +++ b/crates/elp/src/resources/test/config_stanza.stdout @@ -88,6 +88,11 @@ "markdownDescription": "Whether to show the `Link` lenses. Only applies when\n`#elp.lens.enable#` is set.", "type": "boolean" }, + "elp.lens.logview.links": { + "default": false, + "markdownDescription": "Whether to enable LogView lens links.", + "type": "boolean" + }, "elp.lens.run.coverage.enable": { "default": true, "markdownDescription": "Display code coverage information when running tests via the\nCode Lenses. Only applies when `#elp.lens.enabled` and\n`#elp.lens.run.enable#` are set.", @@ -103,6 +108,16 @@ "markdownDescription": "Whether to show the `Run Interactive` lenses. Only applies when\n`#elp.lens.enable#` is set.", "type": "boolean" }, + "elp.lens.scuba.links": { + "default": false, + "markdownDescription": "Whether to enable Scuba lens links.", + "type": "boolean" + }, + "elp.lens.wam.links": { + "default": false, + "markdownDescription": "Whether to enable WAM lens links.", + "type": "boolean" + }, "elp.log": { "default": "error", "markdownDescription": "Configure LSP-based logging using env_logger syntax.", diff --git a/crates/elp/src/to_proto.rs b/crates/elp/src/to_proto.rs index efb763d442..fe98d1c9a3 100644 --- a/crates/elp/src/to_proto.rs +++ b/crates/elp/src/to_proto.rs @@ -26,6 +26,7 @@ use elp_ide::HoverAction; use elp_ide::InlayHintLabel; use elp_ide::InlayHintLabelPart; use elp_ide::InlayKind; +use elp_ide::LinkKind; use elp_ide::NavigationTarget; use elp_ide::Runnable; use elp_ide::RunnableKind; @@ -778,7 +779,14 @@ pub(crate) fn code_lens( } } AnnotationKind::Link(link) => { - if lens_config.links { + if lens_config.links + && match link.kind { + LinkKind::LogView => lens_config.logview_links, + LinkKind::Scuba => lens_config.scuba_links, + LinkKind::WAM => lens_config.wam_links, + _ => true, + } + { let annotation_range = range(line_index, annotation.range); let url = link.url; let text = link.text; diff --git a/crates/ide/src/annotations.rs b/crates/ide/src/annotations.rs index 97b67811c3..7565b8783b 100644 --- a/crates/ide/src/annotations.rs +++ b/crates/ide/src/annotations.rs @@ -37,8 +37,19 @@ pub enum AnnotationKind { Link(Link), } +#[derive(Debug)] +pub enum LinkKind { + Configerator, + ExDocs, + LogView, + ODS, + Scuba, + WAM, +} + #[derive(Debug)] pub struct Link { + pub kind: LinkKind, pub file_id: FileId, pub text_range: TextRange, pub url: String, diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index 51bc919b43..5b55b0c77b 100644 --- a/crates/ide/src/lib.rs +++ b/crates/ide/src/lib.rs @@ -112,6 +112,7 @@ mod highlight_related; pub use annotations::Annotation; pub use annotations::AnnotationKind; +pub use annotations::LinkKind; pub use codemod_helpers::FunctionMatch; pub use codemod_helpers::MFA; pub use common_test::GroupName; From 12ecfe5bb0e0c6046317d9902fe73ce8d3c91780 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 8 Sep 2025 01:53:36 -0700 Subject: [PATCH 017/314] Fix location of meta-only comment Summary: This was causing a GitHub CI failure: https://github.com/WhatsApp/erlang-language-platform/actions/runs/17499526383/job/49708914718 Reviewed By: alanz Differential Revision: D81902529 fbshipit-source-id: a9007bc6cc61ab52e93217d85036b52ada507250 --- crates/ide/src/diagnostics/debugging_function.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index 6f72cc4f2d..2e8607ccb7 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -22,8 +22,8 @@ use crate::diagnostics::DiagnosticCode; use crate::diagnostics::FunctionCallLinter; use crate::diagnostics::Linter; use crate::diagnostics::Severity; -use crate::diagnostics::meta_only; // @fb-only +use crate::lazy_function_matches; pub(crate) struct NoDebuggingFunctionLinter; From a826c8c47d4d12fea8dd0975c8b18b3d5a7196b7 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 8 Sep 2025 02:51:14 -0700 Subject: [PATCH 018/314] Fix OSS compilation of glean.rs Reviewed By: alanz Differential Revision: D81907094 fbshipit-source-id: 68d4569ad46028f4adda199eeb73cd2035830aa9 --- crates/elp/src/bin/glean.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/elp/src/bin/glean.rs b/crates/elp/src/bin/glean.rs index 64612b65c3..52f1d1ea9c 100644 --- a/crates/elp/src/bin/glean.rs +++ b/crates/elp/src/bin/glean.rs @@ -1784,10 +1784,10 @@ impl GleanIndexer { let source_file = sema.parse(file_id); let range = Self::find_range(sema, ctx, &source_file, &expr_source)?; - // Check if this is a WAM event record and build link - use elp_ide::meta_only::wam_links; - let wam_ctx = wam_links::WamEventCtx::new(sema.db.upcast()); - let wam_url = wam_ctx.build_wam_link(name).map(|link| link.url()); + // @fb-only + // @fb-only + // @fb-only + let wam_url = None; // @oss-only Some(XRef { source: range.into(), From 1f0e95596d0b22cbb350a46ff3d9bad4100da123 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Mon, 8 Sep 2025 03:07:32 -0700 Subject: [PATCH 019/314] detect new project source files Summary: # Context When working with a buck2 project, we do not simply use source roots, as a given buck2 target can be configured to use files and dependencies from anywhere. To cope with this, we build an include-file lookup structure at project loading time, which is partly populated with information from the output of the bxl query. If a new `.hrl` or `.erl` file is added, it is not obvious which target is should be part of, to update the relevant application and include file lookup table. # This Diff This diff takes a conservative approach to files being added, as they should be a reasonable rare occurrence. It checks for a notification of a newly created source file (once in the `Running` state), and if so triggers a project reload. The query for this happens in a separate thread, and when there is a valid result it is applied. Since there is only an additional file, most of salsa should still be valid, so it does not cause cascading changes. Existing file ids are retained, as Vfs is not flushed in the process. Reviewed By: robertoaloi Differential Revision: D81783795 fbshipit-source-id: 19012379179d0d841bc4b0b24ccd3011e1bfab5d --- crates/elp/src/server.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/elp/src/server.rs b/crates/elp/src/server.rs index d9b7e1ca26..634854bf24 100644 --- a/crates/elp/src/server.rs +++ b/crates/elp/src/server.rs @@ -201,6 +201,23 @@ pub enum Status { ShuttingDown, } +/// For buck projects we cannot rely on source roots, so if a new file +/// is added we need to query the project model for where it fits in. +/// But we must distinguish between the initial load, where every file +/// shows up as newly created, and files added after the initial load. +/// There is a wrinkle, in that we receive a VFS loader complete +/// message immediately after the last batch of files is loaded, but +/// before we have processed changes. In order to not trigger a +/// project reload through thinking this are newly added, we introduce +/// a state tracker, which steps to the final state after the VFS +/// changes are processed. +#[derive(PartialEq)] +pub enum InitialLoading { + Initial, + DoneButVfsChanges, + Done, +} + impl Status { pub fn as_lsp_status(&self) -> lsp_ext::Status { match self { @@ -251,6 +268,7 @@ pub struct Server { status: Status, projects: Arc>, project_loader: Arc>, + initial_load_status: InitialLoading, reload_manager: Arc>, unresolved_app_id_paths: Arc>, update_app_data_ids: bool, @@ -317,6 +335,7 @@ impl Server { status: Status::Initialising, projects: Arc::new(vec![]), project_loader: Arc::new(Mutex::new(ProjectLoader::new())), + initial_load_status: InitialLoading::Initial, reload_manager: Arc::new(Mutex::new(ReloadManager::new())), unresolved_app_id_paths: Arc::new(FxHashMap::default()), update_app_data_ids: false, @@ -934,6 +953,7 @@ impl Server { self.show_message(params); } self.transition(Status::Running); + self.initial_load_status = InitialLoading::DoneButVfsChanges; self.schedule_compile_deps(); self.schedule_cache(); // Not all clients send config in the `initialize` message, request it @@ -1027,6 +1047,15 @@ impl Server { .write() .insert(file.file_id, line_endings); } + + if self.initial_load_status == InitialLoading::Done + && let vfs::Change::Create(_, _) = &file.change + && let Some(path) = vfs.file_path(file.file_id).as_path() + && (path.extension() == Some("hrl") || path.extension() == Some("erl")) + { + self.reload_manager.lock().add(path.to_path_buf()); + } + if let Some(path) = vfs.file_path(file.file_id).as_path() && let Some(app_data_id) = self.unresolved_app_id_paths.get(&path.to_path_buf()) { @@ -1052,6 +1081,9 @@ impl Server { raw_database.set_file_text(file.file_id, Arc::from("")); }; } + if self.initial_load_status == InitialLoading::DoneButVfsChanges { + self.initial_load_status = InitialLoading::Done; + } if self.update_app_data_ids { // We process the changes for files opened before project From a787db0b483297e1b7d7001f02356f0a9f3311c0 Mon Sep 17 00:00:00 2001 From: Tom Davies Date: Mon, 8 Sep 2025 08:11:33 -0700 Subject: [PATCH 020/314] Partially fix escaping when pretty printing strings Summary: Previously, we didn't escape erlang strings and atoms correctly - we'd wrap them in quotes when pretty printing, but didn't escape Erlang's special characters. We add that here. **This does not offer a complete solution**, merely a step in the right direction: We don't try to handle `erl_features` keywords, for example, since they are much more complex to track. See official Erlang implementation as a reference: https://github.com/erlang/otp/blob/ae81b2f6ff2d541c01242f12cdbd5238aa4b26bd/lib/stdlib/src/io_lib.erl#L971 Reviewed By: alanz Differential Revision: D81776458 fbshipit-source-id: 0d7058d6e4c564005b3c4d5bc56846dd3938f277 --- crates/elp/src/bin/glean.rs | 12 +- crates/hir/src/body/pretty.rs | 20 +- crates/hir/src/body/tests.rs | 373 ++++++++++++++++----------------- crates/hir/src/lib.rs | 1 + crates/hir/src/quote.rs | 293 ++++++++++++++++++++++++++ crates/ide/src/expand_macro.rs | 78 +++---- 6 files changed, 537 insertions(+), 240 deletions(-) create mode 100644 crates/hir/src/quote.rs diff --git a/crates/elp/src/bin/glean.rs b/crates/elp/src/bin/glean.rs index 52f1d1ea9c..f9bf740eac 100644 --- a/crates/elp/src/bin/glean.rs +++ b/crates/elp/src/bin/glean.rs @@ -2562,7 +2562,7 @@ mod tests { baz(1) -> ?TAU; %% ^^^ macro.erl/macro/TAU/117/no_ods/6.28 baz(N) -> ?MAX(N, 200). - %% ^^^ macro.erl/macro/MAX/137/no_ods/if (N > 200) -> N; 'true' -> 200 end + %% ^^^ macro.erl/macro/MAX/137/no_ods/if (N > 200) -> N; true -> 200 end "#; xref_v2_check(spec); @@ -2575,7 +2575,7 @@ mod tests { -module(macro). -define(COUNT_INFRA(X), wa_stats_counter:count(X)). baz(atom) -> ?COUNT_INFRA(atom), - %% ^^^^^^^^^^^ macro.erl/macro/COUNT_INFRA/94/has_ods/'wa_stats_counter':'count'( 'atom' ) + %% ^^^^^^^^^^^ macro.erl/macro/COUNT_INFRA/94/has_ods/wa_stats_counter:count( atom ) "#; // @fb-only @@ -2588,7 +2588,7 @@ mod tests { -module(macro). -define(LOG_E(X), (fun() -> wa_log:send_if(X) end)()). baz(atom) -> ?LOG_E("test"), - %% ^^^^^ macro.erl/macro/LOG_E/97/has_logview/fun () -> 'wa_log':'send_if'( "test" ) end() + %% ^^^^^ macro.erl/macro/LOG_E/97/has_logview/fun () -> wa_log:send_if( "test" ) end() "#; // @fb-only @@ -2601,7 +2601,7 @@ mod tests { -module(macro). -define(LOG_SCUBA(X), wa_scuba:log_tablename(pii, X)). baz(atom) -> ?LOG_SCUBA(test_event), - %% ^^^^^^^^^ macro.erl/macro/LOG_SCUBA/97/has_scuba/'wa_scuba':'log_tablename'( 'pii', 'test_event' ) + %% ^^^^^^^^^ macro.erl/macro/LOG_SCUBA/97/has_scuba/wa_scuba:log_tablename( pii, test_event ) "#; // @fb-only @@ -2629,7 +2629,7 @@ mod tests { -define(TYPE, integer()). -spec baz(ok) -> ?TYPE. - %% ^^^^ macro.erl/macro/TYPE/73/no_ods/'erlang':'integer'() + %% ^^^^ macro.erl/macro/TYPE/73/no_ods/erlang:integer() baz(ok) -> 1. "#; @@ -2643,7 +2643,7 @@ mod tests { -module(macro). -define(FOO(X), X). -wild(?FOO(atom)). - %% ^^^ macro.erl/macro/FOO/53/no_ods/'atom' + %% ^^^ macro.erl/macro/FOO/53/no_ods/atom "#; xref_v2_check(spec); diff --git a/crates/hir/src/body/pretty.rs b/crates/hir/src/body/pretty.rs index fae31c3b0a..e19c97e637 100644 --- a/crates/hir/src/body/pretty.rs +++ b/crates/hir/src/body/pretty.rs @@ -8,6 +8,7 @@ * above-listed licenses. */ +use core::iter::Iterator; use std::fmt; use std::fmt::Write as _; use std::str; @@ -48,6 +49,7 @@ use crate::expr::Guards; use crate::expr::MaybeExpr; use crate::expr::SsrPlaceholder; use crate::expr::StringVariant; +use crate::quote; pub fn print_function_clause( db: &dyn InternDatabase, @@ -849,12 +851,20 @@ impl<'a> Printer<'a> { fn print_literal(&mut self, lit: &Literal) -> fmt::Result { match lit { - Literal::String(StringVariant::Normal(string)) => write!(self, "{string:?}"), - Literal::String(StringVariant::Verbatim(string)) => { - write!(self, "{string}") + Literal::String(StringVariant::Normal(string)) => { + write!(self, "{}", quote::escape_and_quote_string(string)) + } + Literal::String(StringVariant::Verbatim(string)) => { + write!(self, "{}", quote::escape_and_quote_string(string)) + } + Literal::Char(char) => write!(self, "${char}"), // TODO Escaping + Literal::Atom(atom) => { + write!( + self, + "{}", + quote::escape_and_quote_atom(self.db.lookup_atom(*atom).as_str()) + ) } - Literal::Char(char) => write!(self, "${char}"), - Literal::Atom(atom) => write!(self, "'{}'", self.db.lookup_atom(*atom)), Literal::Integer(int) => write!(self, "{}", int.value), // TODO: other bases Literal::Float(float) => write!(self, "{}", f64::from_bits(*float)), } diff --git a/crates/hir/src/body/tests.rs b/crates/hir/src/body/tests.rs index f8c48a7fbc..dd5e5e4699 100644 --- a/crates/hir/src/body/tests.rs +++ b/crates/hir/src/body/tests.rs @@ -162,8 +162,8 @@ fn simple() { foo(ok) -> ok. "#, expect![[r#" - foo('ok') -> - 'ok'. + foo(ok) -> + ok. "#]], ); } @@ -304,8 +304,8 @@ foo({a, b}) -> {1, 2, 3}. "#, expect![[r#" foo({ - 'a', - 'b' + a, + b }) -> { 1, @@ -324,8 +324,8 @@ foo([a, b]) -> [1, 2, 3]. "#, expect![[r#" foo([ - 'a', - 'b' + a, + b ]) -> [ 1, @@ -341,8 +341,8 @@ foo([a | b]) -> [1, 2 | 3]. "#, expect![[r#" foo([ - 'a' - | 'b' + a + | b ]) -> [ 1, @@ -400,14 +400,14 @@ fn map() { r#" foo(#{1 + 2 := 3 + 4}) -> #{a => b}. "#, - expect![[r##" + expect![[r#" foo(#{ (1 + 2) := (3 + 4) }) -> #{ - 'a' => 'b' + a => b }. - "##]], + "#]], ); } @@ -417,15 +417,15 @@ fn map_update() { r#" foo() -> #{a => b}#{a := b, c => d}. "#, - expect![[r##" + expect![[r#" foo() -> #{ - 'a' => 'b' + a => b }#{ - 'a' := 'b', - 'c' => 'd' + a := b, + c => d }. - "##]], + "#]], ); } @@ -474,18 +474,18 @@ fn record_update() { foo1() -> Expr#record{field = undefined}. foo2() -> Expr#record{field = ok, missing = }. "#, - expect![[r##" + expect![[r#" foo1() -> Expr#record{ - field = 'undefined' + field = undefined }. foo2() -> Expr#record{ - field = 'ok', + field = ok, missing = [missing] }. - "##]], + "#]], ); } @@ -563,13 +563,13 @@ foo() -> foo() -> case (1 + 2) of X when - (X andalso 'true'); + (X andalso true); (X < 100), (X >= 5) -> - 'ok'; + ok; _ -> - 'error' + error end. "#]], ); @@ -589,14 +589,14 @@ foo() -> expect![[r#" foo() -> receive - 'ok' when - 'true' + ok when + true -> - 'ok'; + ok; _ -> - 'error' + error after Timeout -> - 'timeout' + timeout end. "#]], ); @@ -614,12 +614,12 @@ foo() -> "#, expect![[r#" foo() -> - 'foo'(), - 'erlang':'size'( + foo(), + erlang:size( A ), - 'size'(), - 'foo':'bar'( + size(), + foo:bar( A ). "#]], @@ -638,9 +638,9 @@ foo() -> "#, expect![[r#" foo() -> - fun 'foo'/1, - fun 'erlang':'halt'/0, - fun 'mod':'foo'/1, + fun foo/1, + fun erlang:halt/0, + fun mod:foo/1, fun Mod:Foo/Arity. "#]], ); @@ -658,12 +658,12 @@ foo() -> expect![[r#" foo() -> if - 'erlang':'is_atom'( + erlang:is_atom( X ) -> - 'ok'; - 'true' -> - 'error' + ok; + true -> + error end. "#]], ); @@ -690,16 +690,16 @@ foo() -> 2 of _ -> - 'ok' + ok catch Pat when - 'true' + true -> - 'ok'; - 'error':'undef':Stack -> + ok; + error:undef:Stack -> Stack after - 'ok' + ok end. "#]], ); @@ -817,10 +817,10 @@ foo() -> expect![[r#" foo() -> fun - ('ok') -> - 'ok'; - ('error') -> - 'error' + (ok) -> + ok; + (error) -> + error end, fun Named() -> @@ -839,8 +839,8 @@ foo((ok), ()) -> (). "#, expect![[r#" - foo('ok', [missing]) -> - 'ok', + foo(ok, [missing]) -> + ok, [missing]. "#]], ); @@ -880,7 +880,7 @@ foo(bar()) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -893,7 +893,7 @@ foo(catch 1) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -906,7 +906,7 @@ foo(X#{}) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -945,7 +945,7 @@ foo(X#foo.bar) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -958,7 +958,7 @@ foo(X#foo{}) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -984,7 +984,7 @@ foo(fun() -> ok end) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -997,7 +997,7 @@ foo(<>, [Byte || Byte <- List]]) -> ok. "#, expect![[r#" foo([missing], [missing]) -> - 'ok'. + ok. "#]], ); } @@ -1010,7 +1010,7 @@ foo(begin foo end) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -1023,7 +1023,7 @@ foo(case X of _ -> ok end) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -1036,7 +1036,7 @@ foo(fun erlang:self/0, fun foo/2) -> ok. "#, expect![[r#" foo([missing], [missing]) -> - 'ok'. + ok. "#]], ); } @@ -1062,7 +1062,7 @@ foo(if true -> ok end) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -1088,7 +1088,7 @@ foo(receive _ -> ok after X -> timeout end) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -1101,7 +1101,7 @@ foo(try 1 of _ -> ok catch _ -> error end) -> ok. "#, expect![[r#" foo([missing]) -> - 'ok'. + ok. "#]], ); } @@ -1132,8 +1132,8 @@ foo() -> expect![[r#" foo() -> case X of - 'ok' -> - 'ok' + ok -> + ok end. "#]], ); @@ -1159,7 +1159,7 @@ fn simple_type() { -type foo() :: ok. "#, expect![[r#" - -type foo() :: 'ok'. + -type foo() :: ok. "#]], ); } @@ -1171,7 +1171,7 @@ fn simple_nominal_type() { -nominal foo() :: ok. "#, expect![[r#" - -nominal foo() :: 'ok'. + -nominal foo() :: ok. "#]], ); } @@ -1183,7 +1183,7 @@ fn simple_opaque() { -opaque foo() :: ok. "#, expect![[r#" - -opaque foo() :: 'ok'. + -opaque foo() :: ok. "#]], ); } @@ -1219,7 +1219,7 @@ fn ann_type() { -type foo() :: A :: any(). "#, expect![[r#" - -type foo() :: (A :: 'erlang':'any'()). + -type foo() :: (A :: erlang:any()). "#]], ); } @@ -1232,9 +1232,9 @@ fn list_type() { -type bar() :: [bar, ...]. "#, expect![[r#" - -type foo() :: ['foo']. + -type foo() :: [foo]. - -type bar() :: ['bar', ...]. + -type bar() :: [bar, ...]. "#]], ); } @@ -1247,9 +1247,9 @@ fn tuple_type() { "#, expect![[r#" -type foo() :: { - 'a', - 'b', - 'c' + a, + b, + c }. "#]], ); @@ -1273,12 +1273,12 @@ fn map_type() { r#" -type foo() :: #{a => b, c := d}. "#, - expect![[r##" + expect![[r#" -type foo() :: #{ - 'a' => 'b', - 'c' := 'd' + a => b, + c := d }. - "##]], + "#]], ); } @@ -1294,11 +1294,11 @@ fn fun_type() { expect![[r#" -type foo1() :: fun(). - -type foo2() :: fun(() -> 'ok'). + -type foo2() :: fun(() -> ok). - -type foo3() :: fun(('a', 'b') -> 'ok'). + -type foo3() :: fun((a, b) -> ok). - -type foo4() :: fun((...) -> 'ok'). + -type foo4() :: fun((...) -> ok). "#]], ); } @@ -1313,22 +1313,22 @@ fn union_type() { "#, expect![[r#" -type foo1() :: ( - 'a' | - 'b' + a | + b ). -type foo2() :: ( - 'a' | - 'b' | - 'c' + a | + b | + c ). -type foo3() :: ( ( - 'a' | - 'b' + a | + b ) | - 'c' + c ). "#]], ); @@ -1354,17 +1354,17 @@ fn call_type() { -type remote(A) :: module:remote(A | integer()). "#, expect![[r#" - -type local(A) :: 'local'( + -type local(A) :: local( ( A | - 'erlang':'integer'() + erlang:integer() ) ). - -type remote(A) :: 'module':'remote'( + -type remote(A) :: module:remote( ( A | - 'erlang':'integer'() + erlang:integer() ) ). "#]], @@ -1379,8 +1379,8 @@ fn call_type_erlang_bif() { "#, expect![[r#" -type remote() :: ( - 'erlang':'pid'() | - 'erlang':'pid'() + erlang:pid() | + erlang:pid() ). "#]], ); @@ -1398,7 +1398,7 @@ fn record_type() { -type foo1() :: #record{}. -type foo2(B) :: #record{ - a :: 'erlang':'integer'(), + a :: erlang:integer(), b :: B }. @@ -1429,7 +1429,7 @@ fn simple_spec() { "#, expect![[r#" -spec foo - () -> 'ok'. + () -> ok. "#]], ); } @@ -1442,7 +1442,7 @@ fn simple_callback() { "#, expect![[r#" -callback foo - () -> 'ok'. + () -> ok. "#]], ); } @@ -1456,8 +1456,8 @@ fn multi_sig_spec() { "#, expect![[r#" -spec foo - ('erlang':'atom'()) -> 'erlang':'atom'(); - ('erlang':'integer'()) -> 'erlang':'integer'(). + (erlang:atom()) -> erlang:atom(); + (erlang:integer()) -> erlang:integer(). "#]], ); } @@ -1470,7 +1470,7 @@ fn ann_var_spec() { "#, expect![[r#" -spec foo - ((A :: 'erlang':'any'())) -> 'ok'. + ((A :: erlang:any())) -> ok. "#]], ); } @@ -1485,7 +1485,7 @@ fn guarded_spec() { expect![[r#" -spec foo (A) -> A - when A :: 'erlang':'any'(). + when A :: erlang:any(). "#]], ); } @@ -1509,15 +1509,15 @@ fn record_definition() { }). -record(foo, { - field = 'value' + field = value }). -record(foo, { - field :: 'type' + field :: type }). -record(foo, { - field = 'value' :: 'type' + field = value :: type }). "#]], ); @@ -1531,7 +1531,7 @@ fn simple_term() { -missing_value(). "#, expect![[r#" - -foo('ok'). + -foo(ok). -missing_value([missing]). "#]], @@ -1548,7 +1548,7 @@ fn tuple_term() { -foo({ 1, 2, - 'ok', + ok, "abc" }). "#]], @@ -1645,15 +1645,15 @@ fn binary_op_term() { "#, expect![[r#" -foo({ - 'foo', + foo, 1 }). -compile({ - 'inline', + inline, [ { - 'foo', + foo, 1 } ] @@ -1701,7 +1701,7 @@ foo(1) -> 1; foo(1) -> 1; foo(_) -> - 'ok'. + ok. "#]], ); } @@ -1720,7 +1720,7 @@ foo(1) -> 1. 1. bar(_) -> - 'ok'. + ok. "#]], ); } @@ -1738,7 +1738,7 @@ foo(1) -> 1; foo(1) -> 1; foo(_) -> - 'ok'. + ok. "#]], ); } @@ -1758,7 +1758,7 @@ fn expand_macro_function_multiple_clauses() { foo(1) -> 1; foo(_) -> - 'ok'. + ok. "#]], ); } @@ -1781,7 +1781,7 @@ fn expand_macro_function_multiple_files() { foo(1) -> 1; foo(_) -> - 'ok'. + ok. "#]], ); } @@ -1826,7 +1826,7 @@ foo() -> ?NAME(2). "#, expect![[r#" foo() -> - 'name'( + name( 2 ). "#]], @@ -1843,7 +1843,7 @@ foo() -> ?NAME(2). "#, expect![[r#" foo() -> - 'module':'name'( + module:name( 2 ). "#]], @@ -1862,7 +1862,7 @@ foo(?PAT) -> ok. foo([ _ ]) -> - 'ok'. + ok. "#]], ); } @@ -1879,7 +1879,7 @@ foo(?PAT(_)) -> ok. foo([ _ ]) -> - 'ok'. + ok. "#]], ); } @@ -1894,8 +1894,8 @@ fn expand_macro_type() { "#, expect![[r#" -type foo() :: ( - 'a' | - 'b' + a | + b ). "#]], ); @@ -1910,7 +1910,7 @@ fn expand_macro_type_call() { -type foo() :: ?NAME(). "#, expect![[r#" - -type foo() :: 'name'(). + -type foo() :: name(). "#]], ); } @@ -1924,7 +1924,7 @@ fn expand_macro_remote_type() { -type foo() :: ?NAME(). "#, expect![[r#" - -type foo() :: 'module':'name'(). + -type foo() :: module:name(). "#]], ); } @@ -1939,8 +1939,8 @@ fn expand_macro_var_in_type() { "#, expect![[r#" -type foo() :: ( - 'a' | - 'b' + a | + b ). "#]], ); @@ -1994,11 +1994,11 @@ foo() -> "#, expect![[r#" foo() -> - case 'bar'() of - 'ok' -> - 'ok'; + case bar() of + ok -> + ok; _ -> - 'error' + error end. "#]], ); @@ -2016,8 +2016,8 @@ foo() -> "#, expect![[r#" foo() -> - fun 'local'/1, - fun 'remote':'function'/2. + fun local/1, + fun remote:function/2. "#]], ); } @@ -2034,7 +2034,7 @@ foo() -> expect![[r#" foo() -> #name{ - name = 'name' + name = name }. "#]], ); @@ -2055,28 +2055,28 @@ foo() -> expect![[r#" foo() -> { - 'm1', + m1, { - 'm2', + m2, 1, { - 'm3', + m3, { - 'm3', + m3, 2 } } } }, { - 'm1', + m1, { - 'm2', + m2, A, { - 'm3', + m3, { - 'm3', + m3, B } } @@ -2093,8 +2093,8 @@ fn expand_built_in_function_name() { foo(?FUNCTION_NAME) -> ?FUNCTION_NAME. "#, expect![[r#" - foo('foo') -> - 'foo'. + foo(foo) -> + foo. "#]], ); @@ -2104,7 +2104,7 @@ foo() -> ?FUNCTION_NAME(). "#, expect![[r#" foo() -> - 'foo'(). + foo(). "#]], ); } @@ -2201,10 +2201,10 @@ fn expand_built_in_module() { foo(?MODULE) -> ?MODULE. "#, expect![[r#" - -type foo() :: 'foobar'. + -type foo() :: foobar. - foo('foobar') -> - 'foobar'. + foo(foobar) -> + foobar. "#]], ); } @@ -2318,19 +2318,19 @@ maybe A + B end."#, expect![[r#" - foo() -> - maybe - { - 'ok', - A - } ?= 'a'(), - 'true' = (A >= 0), - { - 'ok', - B - } ?= 'b'(), - (A + B) - end. + foo() -> + maybe + { + ok, + A + } ?= a(), + true = (A >= 0), + { + ok, + B + } ?= b(), + (A + B) + end. "#]], ); } @@ -2349,22 +2349,22 @@ else Other when Other == 0 -> error end."#, expect![[r#" - foo() -> - maybe - { - 'ok', + foo() -> + maybe + { + ok, + A + } ?= a(), + true = (A >= 0), A - } ?= 'a'(), - 'true' = (A >= 0), - A - else - 'error' -> - 'error'; - Other when - (Other == 0) - -> - 'error' - end. + else + error -> + error; + Other when + (Other == 0) + -> + error + end. "#]], ); } @@ -2378,9 +2378,9 @@ fn fundecl_clauses_1() { "#, expect![[r#" foo(0) -> - 'ok'; + ok; foo(_) -> - 'not_ok'. + not_ok. "#]], ); } @@ -2397,10 +2397,7 @@ fn triple_quoted_strings_1() { "#, expect![[r#" foo() -> - """ - hello - there - """. + "\"\"\"\n hello\n there\n \"\"\"". "#]], ); } @@ -2418,11 +2415,7 @@ fn triple_quoted_strings_2() { "#, expect![[r#" foo() -> - """""" - hello - """ - there - """""". + "\"\"\"\"\"\"\n hello\n \"\"\"\n there\n \"\"\"\"\"\"". "#]], ); } @@ -2456,7 +2449,7 @@ macro_rules! my_expect { const QUOTED_BINARY_EXPECT: &str = r#" f() -> << - "ab\"c\"\u{7f}"/utf8 + "ab\"c\"\d"/utf8 >>. "#; @@ -2570,7 +2563,7 @@ fn verbatim_binary_in_verbatim_sigil_tq_string() { const QUOTED_STRING_EXPECT: &str = r#" f() -> - "ab\"c\"\u{7f}". + "ab\"c\"\d". "#; #[test] @@ -2686,7 +2679,7 @@ fn verbatim_binary_sigil_in_pat() { f(<< "ab\"c\"\\d"/utf8 >>) -> - 'ok'. + ok. "#]], ); } @@ -2731,8 +2724,8 @@ fn lowering_with_error_nodes() { f(1a) -> ok begin 1 end. "#, expect![[r#" - f('a') -> - 'ok'. + f(a) -> + ok. "#]], ); } diff --git a/crates/hir/src/lib.rs b/crates/hir/src/lib.rs index 8f285b5005..11d4b40971 100644 --- a/crates/hir/src/lib.rs +++ b/crates/hir/src/lib.rs @@ -26,6 +26,7 @@ mod intern; mod macro_exp; mod module_data; mod name; +pub mod quote; pub mod resolver; pub mod sema; #[cfg(test)] diff --git a/crates/hir/src/quote.rs b/crates/hir/src/quote.rs new file mode 100644 index 0000000000..0dc09f79cb --- /dev/null +++ b/crates/hir/src/quote.rs @@ -0,0 +1,293 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +pub fn escape_and_quote_string(raw: &str) -> String { + escape_and_quote(StringLike::ListString, raw) +} + +pub fn escape_and_quote_binary_string(raw: &str) -> String { + escape_and_quote(StringLike::BinaryString, raw) +} + +pub fn escape_and_quote_atom(raw: &str) -> String { + escape_and_quote(StringLike::Atom, raw) +} + +#[derive(Eq, PartialEq)] +enum StringLike { + Atom, + ListString, + BinaryString, +} + +// See reference implementation: +// https://github.com/erlang/otp/blob/ae81b2f6ff2d541c01242f12cdbd5238aa4b26bd/lib/stdlib/src/io_lib.erl#L1120 +// +// N.B. assumes Erlang's unicode mode +fn escape_and_quote(kind: StringLike, raw: &str) -> String { + let mut result = String::with_capacity(raw.len()); + // Open quotes + match kind { + StringLike::Atom if atom_needs_escape(raw) => result.push('\''), + StringLike::Atom => {} + StringLike::ListString => result.push('\"'), + StringLike::BinaryString => result.push_str("~\""), + } + // String-like contents + for char in raw.chars() { + match char { + '\n' => result.push_str(r"\n"), + '\t' => result.push_str(r"\t"), + '\r' => result.push_str(r"\r"), + '\x0B' => result.push_str(r"\v"), + '\x08' => result.push_str(r"\b"), + '\x0F' => result.push_str(r"\f"), + '\x1B' => result.push_str(r"\e"), + '\x7F' => result.push_str(r"\d"), + '\\' => result.push_str(r"\\"), + '\'' if kind == StringLike::Atom => { + result.push_str("\\'"); + } + '\"' if kind == StringLike::BinaryString || kind == StringLike::ListString => { + result.push_str("\\\""); + } + _ if (' '..='~').contains(&char) => result.push(char), + _ if (char < '\u{00A0}') => { + result.push('\\'); + result.push(char::from_u32((Into::::into(char) >> 6) + 48).unwrap()); + result.push(char::from_u32(((Into::::into(char) >> 3) & 7) + 48).unwrap()); + result.push(char::from_u32((Into::::into(char) & 7) + 48).unwrap()); + } + _ => result.push(char), + } + } + // Close quotes + match kind { + StringLike::Atom if atom_needs_escape(raw) => result.push('\''), + StringLike::Atom => {} + StringLike::ListString => result.push('\"'), + StringLike::BinaryString => result.push('\"'), + } + result +} + +// See: https://github.com/erlang/otp/blob/d04f6a0b261cf23e316e48e2febe14b30c592dc5/lib/stdlib/src/erl_scan.erl#L2272 +pub fn atom_is_reserved_word(raw: &str) -> bool { + match raw { + "after" => true, + "begin" => true, + "case" => true, + "try" => true, + "cond" => true, + "catch" => true, + "andalso" => true, + "orelse" => true, + "end" => true, + "fun" => true, + "if" => true, + "let" => true, + "of" => true, + "receive" => true, + "when" => true, + "bnot" => true, + "not" => true, + "div" => true, + "rem" => true, + "band" => true, + "and" => true, + "bor" => true, + "bxor" => true, + "bsl" => true, + "bsr" => true, + "or" => true, + "xor" => true, + _ => false, + } +} + +pub fn atom_needs_escape(raw: &str) -> bool { + // TODO Handle erl_features too + if raw.is_empty() || atom_is_reserved_word(raw) { + true + } else { + // We already checked that the atom is not empty, so it's safe to unwrap the first char + let first_char = raw.chars().next().unwrap(); + // First char is checked differently to subsequent chars: + // https://github.com/erlang/otp/blob/ae81b2f6ff2d541c01242f12cdbd5238aa4b26bd/lib/stdlib/src/io_lib.erl#L934 + if first_char.is_ascii_lowercase() + || (('ß'..='ÿ').contains(&first_char) && first_char != '÷') + { + // See: https://github.com/erlang/otp/blob/ae81b2f6ff2d541c01242f12cdbd5238aa4b26bd/lib/stdlib/src/io_lib.erl#L943 + !raw.chars().all(|char: char| { + char.is_ascii_lowercase() + || (('ß'..='ÿ').contains(&char) && char != '÷') + || char.is_ascii_uppercase() + || (('À'..='Þ').contains(&char) && char != '×') + || char.is_ascii_digit() + || (char == '_') + || (char == '@') + }) + } else { + true + } + } +} + +#[cfg(test)] +mod tests { + + use expect_test::expect; + + use crate::quote::escape_and_quote_atom; + use crate::quote::escape_and_quote_binary_string; + use crate::quote::escape_and_quote_string; + + #[test] + fn check_simple_atom_that_does_not_need_quotes() { + expect![[r#"foo"#]].assert_eq(&escape_and_quote_atom("foo")) + } + + #[test] + fn check_underscore_atom_does_not_need_quotes() { + expect![[r#"foo_bar"#]].assert_eq(&escape_and_quote_atom("foo_bar")) + } + + #[test] + fn check_initial_underscore_atom_does_need_quotes() { + expect![[r#"'_foo_bar'"#]].assert_eq(&escape_and_quote_atom("_foo_bar")) + } + + #[test] + fn check_lone_underscore_atom_does_need_quotes() { + expect![[r#"'_'"#]].assert_eq(&escape_and_quote_atom("_")) + } + + #[test] + fn check_at_atom_does_not_need_quotes() { + expect![[r#"foo@bar"#]].assert_eq(&escape_and_quote_atom("foo@bar")) + } + + #[test] + fn check_initial_at_atom_does_need_quotes() { + expect![[r#"'@foo'"#]].assert_eq(&escape_and_quote_atom("@foo")) + } + + #[test] + fn check_lone_at_atom_does_need_quotes() { + expect![[r#"'@'"#]].assert_eq(&escape_and_quote_atom("@")) + } + + #[test] + fn check_empty_atom_is_quoted() { + expect![[r#"''"#]].assert_eq(&escape_and_quote_atom("")) + } + + #[test] + fn check_simple_string() { + expect![[r#""foo""#]].assert_eq(&escape_and_quote_string("foo")) + } + + #[test] + fn check_simple_binary_string() { + expect![[r#"~"foo""#]].assert_eq(&escape_and_quote_binary_string("foo")) + } + + #[test] + fn check_emoji_atom() { + expect![[r#"'🦹🏻‍♂️'"#]].assert_eq(&escape_and_quote_atom("🦹🏻‍♂️")) + } + + #[test] + fn check_emoji_string() { + expect![[r#""🦹🏻‍♂️""#]].assert_eq(&escape_and_quote_string("🦹🏻‍♂️")) + } + + #[test] + fn check_emoji_binary_string() { + expect![[r#"~"🦹🏻‍♂️""#]].assert_eq(&escape_and_quote_binary_string("🦹🏻‍♂️")) + } + + #[test] + fn check_erlang_escape_chars_atom() { + expect![[r#"'\n\t\r\v\b\f\e\d'"#]] + .assert_eq(&escape_and_quote_atom("\n\t\r\x0B\x08\x0F\x1B\x7F")) + } + + #[test] + fn check_erlang_escape_chars_string() { + expect![[r#""\n\t\r\v\b\f\e\d""#]] + .assert_eq(&escape_and_quote_string("\n\t\r\x0B\x08\x0F\x1B\x7F")) + } + + #[test] + fn check_erlang_escape_chars_binary_string() { + expect![[r#"~"\n\t\r\v\b\f\e\d""#]].assert_eq(&escape_and_quote_binary_string( + "\n\t\r\x0B\x08\x0F\x1B\x7F", + )) + } + + #[test] + fn check_erlang_backslash_char_atom() { + expect![[r#"'\\'"#]].assert_eq(&escape_and_quote_atom(r"\")) + } + + #[test] + fn check_erlang_backslash_char_string() { + expect![[r#""\\""#]].assert_eq(&escape_and_quote_string(r"\")) + } + + #[test] + fn check_erlang_backslash_char_binary_string() { + expect![[r#"~"\\""#]].assert_eq(&escape_and_quote_binary_string(r"\")) + } + + #[test] + fn check_erlang_quote_char_atom() { + expect![[r#"'\''"#]].assert_eq(&escape_and_quote_atom(r"'")) + } + + #[test] + fn check_erlang_quote_char_string() { + expect![[r#""\"""#]].assert_eq(&escape_and_quote_string("\"")) + } + + #[test] + fn check_erlang_quote_char_binary_string() { + expect![[r#"~"\"""#]].assert_eq(&escape_and_quote_binary_string("\"")) + } + + #[test] + fn check_needs_quote_for_simple_atom() { + assert!(!crate::quote::atom_needs_escape("foo")) + } + + #[test] + fn check_needs_quote_for_empty_atom() { + assert!(crate::quote::atom_needs_escape("")) + } + + #[test] + fn check_needs_quote_for_atom_with_special_characters() { + assert!(crate::quote::atom_needs_escape("foo bar")); + assert!(crate::quote::atom_needs_escape("foo÷bar")); + assert!(crate::quote::atom_needs_escape("foo😵‍💫bar")); + assert!(crate::quote::atom_needs_escape("foo'bar")) + } + + #[test] + fn check_atom_is_reserved_keyword_true() { + assert!(crate::quote::atom_is_reserved_word("andalso")) + } + + #[test] + fn check_atom_is_reserved_keyword_false() { + assert!(!crate::quote::atom_is_reserved_word("foo")) + } +} diff --git a/crates/ide/src/expand_macro.rs b/crates/ide/src/expand_macro.rs index 8e0236aaed..95735a904d 100644 --- a/crates/ide/src/expand_macro.rs +++ b/crates/ide/src/expand_macro.rs @@ -116,7 +116,7 @@ bar() -> ?F~OO. "#, expect![[r#" FOO - 'foo' + foo "#]], ); } @@ -131,7 +131,7 @@ bar() -> ?F~OO. "#, expect![[r#" FOO - ('foo' + 1) + (foo + 1) "#]], ); } @@ -146,7 +146,7 @@ bar() -> ?F~OO(4). "#, expect![[r#" FOO/1 - ('foo' + 4) + (foo + 4) "#]], ); } @@ -161,7 +161,7 @@ bar() -> ?F~OO(4,5). "#, expect![[r#" FOO/2 - ((('foo' + 4) + 5) + 1) + (((foo + 4) + 5) + 1) "#]], ); } @@ -176,7 +176,7 @@ bar() -> ?F~OO(4,(baz(42))). "#, expect![[r#" FOO/2 - ((('foo' + 4) + 'baz'( + (((foo + 4) + baz( 42 )) + 1) "#]], @@ -217,7 +217,7 @@ bar() -> ?F~OO(4). "#, expect![[r#" FOO/1 - (('foo' + 4) + [missing]) + ((foo + 4) + [missing]) "#]], ); } @@ -233,7 +233,7 @@ bar() -> ?F~OO(4). "#, expect![[r#" FOO/1 - (('foo' + 4) + 'baz') + ((foo + 4) + baz) "#]], ); } @@ -250,7 +250,7 @@ bar() -> ?F~OO(4). "#, expect![[r#" FOO/1 - (('foo' + 4) + [missing]) + ((foo + 4) + [missing]) "#]], ); } @@ -267,7 +267,7 @@ bar() -> ?F~OO(4). "#, expect![[r#" FOO/1 - (('foo' + 4) + 'foo'( + ((foo + 4) + foo( 6 )) "#]], @@ -338,36 +338,36 @@ baz() -> fun () -> { - 'ok', + ok, Actual - } = 'lookup_mod':'get'( - 'a_mod':'get_val'( + } = lookup_mod:get( + a_mod:get_val( Alice ), - 'val' + val ), [missing], DebugComment = [ { - 'actual', + actual, Actual }, { - 'expected', + expected, [] } ], SortFun = fun (A, B) -> - ('maps':'get'( - 'code', + (maps:get( + code, A - ) =< 'maps':'get'( - 'code', + ) =< maps:get( + code, B )) end, - 'lists':'foreach'( + lists:foreach( fun ({ ExpectedVal, @@ -375,25 +375,25 @@ baz() -> }) -> [missing], [missing], - ExpectedType = case 'val' of - 'all' -> - 'maps':'get'( - 'type', + ExpectedType = case val of + all -> + maps:get( + type, ExpectedVal, - 'missing_expected_type' + missing_expected_type ); _ -> - 'val' + val end, [missing], [missing] end, - 'lists':'zip'( - 'lists':'sort'( + lists:zip( + lists:sort( SortFun, [] ), - 'lists':'sort'( + lists:sort( SortFun, Actual ) @@ -428,15 +428,15 @@ baz() -> "#, expect![[r#" assertQrs/3 - ExpectedType = case 'qr_type_message' of - 'qr_type_all' -> - 'maps':'get'( - 'type', + ExpectedType = case qr_type_message of + qr_type_all -> + maps:get( + type, ExpectedQr, - 'missing_expected_type' + missing_expected_type ); _ -> - 'qr_type_message' + qr_type_message end "#]], ); @@ -463,7 +463,7 @@ baz() -> FOO/1 case 3 of 1 -> - 'one'; + one; _ -> 3 end @@ -500,7 +500,7 @@ f() -> "#, expect![[r#" C - 'm':'f'() + m:f() "#]], ); } @@ -515,7 +515,7 @@ f() -> "#, expect![[r#" C - 'm':'f'( + m:f( 1, 2 ) @@ -566,7 +566,7 @@ get_partition(Who) -> "#, expect![[r#" HASH_FUN - 'wa_pg2':'hash'( + wa_pg2:hash( Who, 5 ) From fb24c22520aeb197f40ec869a75a14acc49085f7 Mon Sep 17 00:00:00 2001 From: Tom Davies Date: Mon, 8 Sep 2025 08:11:33 -0700 Subject: [PATCH 021/314] Add linter to find and fix expressions which could be string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Diagnostic and quick fix for simple string-related expressions which would generally be better as literals. e.g.: ```erlang fn() -> atom_to_list(foo). %% ^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a string literal. ``` becomes ```erlang fn() -> "foo". ``` Reviewed By: alanz Differential Revision: D81578353 fbshipit-source-id: 51d73cd4ad9c01b39d90f97115f89d8469bdea16 --- crates/ide/src/diagnostics.rs | 5 +- .../diagnostics/could_be_a_string_literal.rs | 997 ++++++++++++++++++ crates/ide_db/src/diagnostic_code.rs | 5 +- crates/ide_ssr/src/lib.rs | 10 + crates/ide_ssr/src/matching.rs | 25 + website/docs/erlang-error-index/w/W0055.md | 47 + 6 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 crates/ide/src/diagnostics/could_be_a_string_literal.rs create mode 100644 website/docs/erlang-error-index/w/W0055.md diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 5e65ca5f45..22d8f7e133 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -89,6 +89,7 @@ mod application_env; mod atoms_exhaustion; mod binary_string_to_sigil; mod boolean_precedence; +mod could_be_a_string_literal; mod cross_node_eval; mod debugging_function; mod dependent_header; @@ -743,7 +744,8 @@ impl SsrPatternsDiagnostics for T { ) -> Vec { let mut res = Vec::new(); for (pattern, context) in self.patterns() { - let matches = match_pattern_in_file_functions(sema, self.strategy(), file_id, &pattern); + let strategy = self.strategy(); + let matches = match_pattern_in_file_functions(sema, strategy, file_id, &pattern); for matched in &matches.matches { if Some(true) == self.is_match_valid(&context, matched, sema, file_id) { let message = self.pattern_description(&context); @@ -1348,6 +1350,7 @@ const SSR_PATTERN_LINTERS: &[&dyn SsrPatternsDiagnostics] = &[ &unnecessary_fold_to_build_map::LINTER, &binary_string_to_sigil::LINTER, &unnecessary_map_to_list_in_comprehension::LINTER, + &could_be_a_string_literal::LINTER, ]; /// Unified registry for all types of linters diff --git a/crates/ide/src/diagnostics/could_be_a_string_literal.rs b/crates/ide/src/diagnostics/could_be_a_string_literal.rs new file mode 100644 index 0000000000..0091f559f5 --- /dev/null +++ b/crates/ide/src/diagnostics/could_be_a_string_literal.rs @@ -0,0 +1,997 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +use elp_ide_db::DiagnosticCode; +use elp_ide_db::elp_base_db::FileId; +use elp_ide_db::source_change::SourceChangeBuilder; +use hir::Semantic; +use hir::Strategy; +use hir::StringVariant; +use hir::fold::MacroStrategy; +use hir::fold::ParenStrategy; +use hir::quote::escape_and_quote_atom; +use hir::quote::escape_and_quote_binary_string; +use hir::quote::escape_and_quote_string; + +use crate::Assist; +use crate::diagnostics::Linter; +use crate::diagnostics::Severity; +use crate::diagnostics::SsrPatternsLinter; +use crate::fix; + +pub(crate) static LINTER: CouldBeAStringLiteralLinter = CouldBeAStringLiteralLinter; + +pub(crate) struct CouldBeAStringLiteralLinter; + +impl Linter for CouldBeAStringLiteralLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::CouldBeAStringLiteral + } + + fn description(&self) -> String { + "Could be rewritten as a literal.".to_string() + } + + fn severity(&self) -> Severity { + Severity::Information + } +} + +const STRING_VAR: &str = "_@StringLike"; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) enum StringKind { + List, + Binary, + Atom, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub(crate) struct StringRewrite { + from: StringKind, + to: StringKind, +} + +impl SsrPatternsLinter for CouldBeAStringLiteralLinter { + type Context = StringRewrite; + + fn patterns(&self) -> Vec<(String, Self::Context)> { + vec![ + ( + format!("ssr: list_to_binary({STRING_VAR})."), + StringRewrite { + from: StringKind::List, + to: StringKind::Binary, + }, + ), + ( + format!("ssr: list_to_atom({STRING_VAR})."), + StringRewrite { + from: StringKind::List, + to: StringKind::Atom, + }, + ), + ( + format!("ssr: atom_to_list({STRING_VAR})."), + StringRewrite { + from: StringKind::Atom, + to: StringKind::List, + }, + ), + ( + format!("ssr: atom_to_binary({STRING_VAR})."), + StringRewrite { + from: StringKind::Atom, + to: StringKind::Binary, + }, + ), + ] + } + + fn pattern_description(&self, context: &Self::Context) -> String { + match context.to { + StringKind::List => "Could be rewritten as a string literal.".to_string(), + StringKind::Binary => "Could be rewritten as a binary string literal.".to_string(), + StringKind::Atom => "Could be rewritten as an atom literal.".to_string(), + } + } + + fn is_match_valid( + &self, + context: &Self::Context, + matched: &elp_ide_ssr::Match, + sema: &Semantic, + file_id: FileId, + ) -> Option { + if matched.range.file_id != file_id { + // We've somehow ended up with a match in a different file - this means we've + // accidentally expanded a macro from a different file, or some other complex case that + // gets hairy, so bail out. + return None; + } + if let Some(comments) = matched.comments(sema) { + // Avoid clobbering comments in the original source code + if !comments.is_empty() { + return None; + } + } + + if let Some(true) = matched.placeholder_is_macro(sema, STRING_VAR) { + None + } else { + match context.from { + StringKind::List => Some(Option::is_some( + &matched.placeholder_is_string(sema, STRING_VAR), + )), + StringKind::Binary => None, // Possible future work + StringKind::Atom => Some(Option::is_some( + &matched.placeholder_is_atom(sema, STRING_VAR), + )), + } + } + } + + fn fixes( + &self, + context: &Self::Context, + matched: &elp_ide_ssr::Match, + sema: &Semantic, + file_id: FileId, + ) -> Option> { + let unnecessary_non_literal_range = matched.range.range; + let mut builder = SourceChangeBuilder::new(file_id); + match *context { + StringRewrite { + from: StringKind::List, + to: StringKind::Binary, + } => { + if let StringVariant::Normal(list_string_value) = + matched.placeholder_is_string(sema, STRING_VAR)? + { + builder.replace( + unnecessary_non_literal_range, + escape_and_quote_binary_string(&list_string_value), + ); + Some(vec![fix( + "rewrite_as_a_binary_string_literal", + "Rewrite as a binary string literal", + builder.finish(), + unnecessary_non_literal_range, + )]) + } else { + None + } + } + StringRewrite { + from: StringKind::List, + to: StringKind::Atom, + } => { + if let StringVariant::Normal(list_string_value) = + matched.placeholder_is_string(sema, STRING_VAR)? + { + builder.replace( + unnecessary_non_literal_range, + escape_and_quote_atom(&list_string_value), + ); + Some(vec![fix( + "rewrite_as_an_atom_literal", + "Rewrite as an atom literal", + builder.finish(), + unnecessary_non_literal_range, + )]) + } else { + None + } + } + StringRewrite { + from: StringKind::Atom, + to: StringKind::List, + } => { + let atom = matched.placeholder_is_atom(sema, STRING_VAR)?; + builder.replace( + unnecessary_non_literal_range, + escape_and_quote_string(&atom.as_string(sema.db.upcast())), + ); + Some(vec![fix( + "rewrite_as_a_string_literal", + "Rewrite as a string literal", + builder.finish(), + unnecessary_non_literal_range, + )]) + } + StringRewrite { + from: StringKind::Atom, + to: StringKind::Binary, + } => { + let atom = matched.placeholder_is_atom(sema, STRING_VAR)?; + let list_string = escape_and_quote_binary_string(&atom.as_string(sema.db.upcast())); + builder.replace(unnecessary_non_literal_range, list_string); + Some(vec![fix( + "rewrite_as_a_binary_string_literal", + "Rewrite as a binary string literal", + builder.finish(), + unnecessary_non_literal_range, + )]) + } + _ => None, + } + } + + fn strategy(&self) -> Strategy { + Strategy { + // Macros are a form of documentation and normalization, so we should not inline them + macros: MacroStrategy::DoNotExpand, + parens: ParenStrategy::InvisibleParens, + } + } +} + +#[cfg(test)] +mod tests { + + use expect_test::Expect; + use expect_test::expect; + + use crate::diagnostics::Diagnostic; + use crate::diagnostics::DiagnosticCode; + use crate::tests; + + fn filter(d: &Diagnostic) -> bool { + d.code == DiagnosticCode::CouldBeAStringLiteral + } + + #[track_caller] + fn check_diagnostics(fixture: &str) { + tests::check_filtered_diagnostics(fixture, &filter) + } + + #[track_caller] + fn check_fix(fixture_before: &str, fixture_after: Expect) { + tests::check_fix(fixture_before, fixture_after) + } + + #[test] + fn detects_list_to_binary() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> list_to_binary("foo"). + %% ^^^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a binary string literal. + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_list_to_binary() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_binary("foo"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"foo". + + "#]], + ) + } + + #[test] + fn detects_list_to_binary_fully_qualified() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:list_to_binary("foo"). + %% ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a binary string literal. + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_list_to_binary_fully_qualified() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:li~st_to_binary("foo"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"foo". + + "#]], + ) + } + + #[test] + fn detects_list_to_atom() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> list_to_atom("foo"). + %% ^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as an atom literal. + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_list_to_atom() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_atom("foo"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> foo. + + "#]], + ) + } + + #[test] + fn detects_list_to_atom_fully_qualified() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:list_to_atom("foo"). + %% ^^^^^^^^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as an atom literal. + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_list_to_atom_fully_qualified() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:li~st_to_atom("foo"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> foo. + + "#]], + ) + } + + #[test] + fn detects_atom_to_binary() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_to_binary(foo). + %% ^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a binary string literal. + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_atom_to_binary() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_to_b~inary(foo). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"foo". + + "#]], + ) + } + + #[test] + fn detects_atom_to_binary_fully_qualified() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:atom_to_binary(foo). + %% ^^^^^^^^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a binary string literal. + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_atom_to_binary_fully_qualified() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:atom_to_~binary(foo). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"foo". + + "#]], + ) + } + + #[test] + fn detects_atom_to_list() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_to_list(foo). + %% ^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a string literal. + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_atom_to_list() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_~to_list(foo). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> "foo". + + "#]], + ) + } + + #[test] + fn detects_atom_to_list_fully_qualified() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:atom_to_list(foo). + %% ^^^^^^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a string literal. + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + ) + } + + #[test] + fn fixes_atom_to_list_fully_qualified() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> erlang:at~om_to_list(foo). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> "foo". + + "#]], + ) + } + + #[test] + fn ignores_potential_literals_behind_macros() { + // Macros are a form of documentation and normalization, so we should not + // effectively inline them by rewriting them to some function of their expansion. + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + -define(STR(X), X). + + fn() -> list_to_binary(?STR("foo")). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + ); + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + -define(ATOM(X), X). + + fn() -> atom_to_binary(?ATOM(foo)). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + ); + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + -define(FOO(), "foo"). + + fn() -> list_to_binary(?FOO()). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + ); + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + -define(FOO, "foo"). + + fn() -> list_to_binary(?FOO). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + ); + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + + -define(FOO, foo). + + fn() -> atom_to_list(?FOO). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + ) + } + + // ================= Dealing with special characters ================= + + #[test] + fn fixes_list_to_binary_emoji() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_binary("fo🥰o"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"fo🥰o". + + "#]], + ) + } + + #[test] + fn fixes_list_to_atom_emoji() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_atom("fo🦹🏻‍♂️o"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_List) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> 'fo🦹🏻‍♂️o'. + + "#]], + ) + } + + #[test] + fn fixes_atom_to_binary_emoji() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_to_bina~ry('fo🦹🏻‍♂️o'). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"fo🦹🏻‍♂️o". + + "#]], + ) + } + + #[test] + fn fixes_atom_to_list_emoji() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_~to_list('fo🦹🏻‍♂️o'). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> "fo🦹🏻‍♂️o". + + "#]], + ) + } + + #[test] + fn fixes_list_to_binary_quotes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_binary("\"'"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"\"'". + + "#]], + ) + } + + #[test] + fn fixes_list_to_atom_quotes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_atom("\"'"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> '"\''. + + "#]], + ) + } + + #[test] + fn fixes_atom_to_binary_quotes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_to_bin~ary('"\''). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"\"'". + + "#]], + ) + } + + #[test] + fn fixes_atom_to_list_quotes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_~to_list('"\''). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> "\"'". + + "#]], + ) + } + + #[test] + fn fixes_list_to_binary_escapes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_binary("A\sB\nC\tD"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_binary/1]). + list_to_binary(_List) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"A B\nC\tD". + + "#]], + ) + } + + #[test] + fn fixes_list_to_atom_escapes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_atom("A\sB\nC\tD"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> 'A B\nC\tD'. + + "#]], + ) + } + + #[test] + fn fixes_atom_to_binary_escapes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_to_bin~ary('A\sB\nC\tD'). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_binary/1]). + atom_to_binary(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> ~"A B\nC\tD". + + "#]], + ) + } + + #[test] + fn fixes_atom_to_list_escapes() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> atom_~to_list('A\sB\nC\tD'). + + //- /src/erlang.erl + -module(erlang). + -export([atom_to_list/1]). + atom_to_list(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> "A B\nC\tD". + + "#]], + ) + } + + #[test] + fn fixes_list_to_atom_keyword_is_quoted() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_atom("orelse"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> 'orelse'. + + "#]], + ) + } + + #[test] + fn fixes_list_to_atom_underscore_is_quoted() { + check_fix( + r#" + //- /src/main.erl + -module(main). + + fn() -> li~st_to_atom("_"). + + //- /src/erlang.erl + -module(erlang). + -export([list_to_atom/1]). + list_to_atom(_Atom) -> error(not_impl). + "#, + expect![[r#" + -module(main). + + fn() -> '_'. + + "#]], + ) + } +} diff --git a/crates/ide_db/src/diagnostic_code.rs b/crates/ide_db/src/diagnostic_code.rs index 2f91ab668e..9bda306e96 100644 --- a/crates/ide_db/src/diagnostic_code.rs +++ b/crates/ide_db/src/diagnostic_code.rs @@ -88,6 +88,7 @@ pub enum DiagnosticCode { NoCatch, NoErrorLogger, NoNoWarnSuppressions, + CouldBeAStringLiteral, // Wrapper for erlang service diagnostic codes ErlangService(String), @@ -236,7 +237,7 @@ impl DiagnosticCode { DiagnosticCode::NoCatch => "W0052".to_string(), DiagnosticCode::NoErrorLogger => "W0053".to_string(), DiagnosticCode::NoNoWarnSuppressions => "W0054".to_string(), - + DiagnosticCode::CouldBeAStringLiteral => "W0055".to_string(), DiagnosticCode::ErlangService(c) => c.to_string(), DiagnosticCode::Eqwalizer(c) => format!("eqwalizer: {c}"), DiagnosticCode::AdHoc(c) => format!("ad-hoc: {c}"), @@ -332,6 +333,7 @@ impl DiagnosticCode { DiagnosticCode::NoCatch => "no_catch".to_string(), DiagnosticCode::NoErrorLogger => "no_error_logger".to_string(), DiagnosticCode::NoNoWarnSuppressions => "no_nowarn_suppressions".to_string(), + DiagnosticCode::CouldBeAStringLiteral => "could_be_a_binary_string_literal".to_string(), DiagnosticCode::ErlangService(c) => c.to_string(), DiagnosticCode::Eqwalizer(c) => c.to_string(), @@ -422,6 +424,7 @@ impl DiagnosticCode { DiagnosticCode::RecordTupleMatch => true, DiagnosticCode::DebuggingFunction => true, DiagnosticCode::NonStandardIntegerFormatting => true, + DiagnosticCode::CouldBeAStringLiteral => true, // False DiagnosticCode::DefaultCodeForEnumIter => false, diff --git a/crates/ide_ssr/src/lib.rs b/crates/ide_ssr/src/lib.rs index db521cff62..826bec641f 100644 --- a/crates/ide_ssr/src/lib.rs +++ b/crates/ide_ssr/src/lib.rs @@ -618,6 +618,16 @@ impl Match { let body = self.matched_node_body.get_body(sema)?; placeholder_match.is_var(&body) } + + pub fn placeholder_is_macro(&self, sema: &Semantic, placeholder_name: &str) -> Option { + let body = self.matched_node_body.get_body(sema)?; + let body_with_visible_macros = body.index_with_strategy(Strategy { + macros: MacroStrategy::DoNotExpand, + parens: ParenStrategy::InvisibleParens, + }); + let placeholder_match = self.get_placeholder_match(sema, placeholder_name)?; + placeholder_match.is_macro(&body_with_visible_macros) + } } pub struct MatchDebugInfo { diff --git a/crates/ide_ssr/src/matching.rs b/crates/ide_ssr/src/matching.rs index b60100fe4d..2ec0e192ed 100644 --- a/crates/ide_ssr/src/matching.rs +++ b/crates/ide_ssr/src/matching.rs @@ -218,6 +218,31 @@ impl PlaceholderMatch { } } + // N.B. that if the body was not indexed with macros included, you definitely will not find any! + // As such, if you want to find macros you will need to make sure you used `MacroStrategy::DoNotExpand` + // or `MacroStrategy::ExpandButIncludeMacroCall` when constructing the `Body` given as an argument here. + pub fn is_macro(&self, body: &FoldBody) -> Option { + match self.code_id { + SubId::AnyExprId(AnyExprId::Expr(expr_id)) => match &body[expr_id] { + Expr::MacroCall { .. } => Some(true), + _ => Some(false), + }, + SubId::AnyExprId(AnyExprId::Pat(pat_id)) => match &body[pat_id] { + Pat::MacroCall { .. } => Some(true), + _ => Some(false), + }, + SubId::AnyExprId(AnyExprId::TypeExpr(type_expr_id)) => match &body[type_expr_id] { + TypeExpr::MacroCall { .. } => Some(true), + _ => Some(false), + }, + SubId::AnyExprId(AnyExprId::Term(term_id)) => match &body[term_id] { + Term::MacroCall { .. } => Some(true), + _ => Some(false), + }, + _ => Some(false), + } + } + /// Return any comments on the matched item pub fn comments(&self, sema: &Semantic, body_map: &BodySourceMap) -> Option> { match self.code_id { diff --git a/website/docs/erlang-error-index/w/W0055.md b/website/docs/erlang-error-index/w/W0055.md new file mode 100644 index 0000000000..b0a18051e2 --- /dev/null +++ b/website/docs/erlang-error-index/w/W0055.md @@ -0,0 +1,47 @@ +--- +sidebar_position: 55 +--- + +# W0055 - Could be a string literal + +## Information + +```erlang +-module(example). + +foo() -> + atom_to_list(foo). +%% ^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a string literal. + +bar() -> + atom_to_binary(bar). +%% ^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as a binary string literal. + +baz() -> + list_to_atom("baz"). +%% ^^^^^^^^^^^^^^^^^^^ 💡 information: Could be rewritten as an atom literal. +``` + +This diagnostic is triggered when a string or atom literal is immediately converted to a +different string or atom type. + +Whilst the compiler will often optimise these conversions away, it is still good practice to +to use the literal type that is most appropriate for the context in order to keep the code +clear and concise. + +## Fix + +Rewrite the value as a literal of the appropriate type. + +```erlang +-module(example). + +foo() -> + "foo". + +bar() -> + ~"bar". + +baz() -> + baz. +``` From 5ee11110752414ae988258e61fb80179fb442e55 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 10 Sep 2025 03:26:26 -0700 Subject: [PATCH 022/314] Mitigate false positive undefined function for lager Summary: We received various reports of false positives for the `W0017` (`undefined_function`) linter. In both cases this was due to the usage of the [`lager`](https://github.com/erlang-lager/lager) logging library, which relies on a parse transformation, causing functions such as `lager:warning/X` or `lager:error/X` to be recognized as undefined. While the medium-term plan is to allow linters to get a custom config such as: ``` [linters.undefined_function] excludes = [ "lager", "some:call", "some:other/3" ] ``` For the time being, add `lager` to the hard-coded exclusion list, to mitigate the issue, reducing noise for end users. Reviewed By: TD5 Differential Revision: D82094926 fbshipit-source-id: e73eb0ca4315a17e63d35938801badd44d7ac7fd --- crates/hir/src/name.rs | 1 + crates/ide/src/diagnostics/undefined_function.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/hir/src/name.rs b/crates/hir/src/name.rs index 8444289e83..00ce2f89f1 100644 --- a/crates/hir/src/name.rs +++ b/crates/hir/src/name.rs @@ -262,6 +262,7 @@ pub mod known { handle, hidden, is_record, + lager, main, module_info, ok, diff --git a/crates/ide/src/diagnostics/undefined_function.rs b/crates/ide/src/diagnostics/undefined_function.rs index a32bb9765c..9ae17f53a2 100644 --- a/crates/ide/src/diagnostics/undefined_function.rs +++ b/crates/ide/src/diagnostics/undefined_function.rs @@ -148,8 +148,10 @@ fn is_automatically_added(sema: &Semantic, module: Module, function: &Expr, arit function_name_is_behaviour_info && arity == 1 && module_has_callbacks_defined } +// T237551085: Once linters can take a custom configuration via the TOML files, move this to a config fn in_exclusion_list(sema: &Semantic, module: &Expr, function: &Expr, arity: u32) -> bool { sema.is_atom_named(function, &known::module_info) && (arity == 0 || arity == 1) + || sema.is_atom_named(module, &known::lager) || sema.is_atom_named(module, &known::graphql_scanner) || sema.is_atom_named(module, &known::graphql_parser) || sema.is_atom_named(module, &known::thrift_scanner) @@ -593,4 +595,16 @@ private() -> ok. "#, ) } + + #[test] + fn test_exclusion_list() { + check_diagnostics( + r#" + //- /src/main.erl + -module(main). + main() -> + lager:warning("Some ~p", [Message]). + "#, + ) + } } From ff9c0b89c838f12d12735cc1a6dc391c7a82282c Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Thu, 11 Sep 2025 06:06:54 -0700 Subject: [PATCH 023/314] Add support for interactive debugging for rebar3 projects Summary: This diff brings feature parity when it comes to code lenses between `rebar3` and `buck2` projects. Specifically: * Adds the `openInteractive` command to the OSS extension, which starts a rebar3 shell * Adds the `debugInteractive` command to the OSS extension, which starts a rebar3 shell in debug mode (no tests are run) * Adds the `runInteractive` command to the OSS extension, which runs the test in an open shell (either a debugging one or not) * It enables the "debug" and "interactive" lenses by default * It adds some extra logging Reviewed By: alanz Differential Revision: D82101533 fbshipit-source-id: cb051a1b6a968bc603d81d7045bfd9e34ddb5b73 --- crates/elp/src/lsp_ext.rs | 20 +++++++- crates/elp/src/to_proto.rs | 74 +++++++++++++++++++++-------- editors/code/client/src/commands.ts | 68 ++++++++++++++++++++++++-- editors/code/package.json | 4 +- 4 files changed, 140 insertions(+), 26 deletions(-) diff --git a/crates/elp/src/lsp_ext.rs b/crates/elp/src/lsp_ext.rs index ed6c655a75..30b4536223 100644 --- a/crates/elp/src/lsp_ext.rs +++ b/crates/elp/src/lsp_ext.rs @@ -148,11 +148,10 @@ impl Runnable { } } - pub fn rebar3_test( + pub fn rebar3_ct( runnable: elp_ide::Runnable, location: Option, workspace_root: PathBuf, - _coverage_enabled: bool, ) -> Self { Self { label: "Rebar3".to_string(), @@ -165,6 +164,23 @@ impl Runnable { }), } } + + pub fn rebar3_shell( + _runnable: elp_ide::Runnable, + location: Option, + workspace_root: PathBuf, + ) -> Self { + Self { + label: "Rebar3".to_string(), + location, + kind: RunnableKind::Rebar3, + args: RunnableArgs::Rebar3(Rebar3RunnableArgs { + workspace_root, + command: "as".to_string(), + args: vec!["test".to_string(), "shell".to_string()], + }), + } + } } #[derive(Serialize, Deserialize, Debug)] diff --git a/crates/elp/src/to_proto.rs b/crates/elp/src/to_proto.rs index fe98d1c9a3..f9263cfcab 100644 --- a/crates/elp/src/to_proto.rs +++ b/crates/elp/src/to_proto.rs @@ -641,19 +641,16 @@ pub(crate) fn buck2_test_runnable( ) } -pub(crate) fn rebar3_test_runnable( - snap: &Snapshot, - runnable: Runnable, - coverage_enabled: bool, -) -> lsp_ext::Runnable { +pub(crate) fn rebar3_ct_runnable(snap: &Snapshot, runnable: Runnable) -> lsp_ext::Runnable { let file_id = runnable.nav.file_id; let location = location_link(snap, None, runnable.clone().nav).ok(); - lsp_ext::Runnable::rebar3_test( - runnable, - location, - snap.workspace_root(file_id).into(), - coverage_enabled, - ) + lsp_ext::Runnable::rebar3_ct(runnable, location, snap.workspace_root(file_id).into()) +} + +pub(crate) fn rebar3_shell_runnable(snap: &Snapshot, runnable: Runnable) -> lsp_ext::Runnable { + let file_id = runnable.nav.file_id; + let location = location_link(snap, None, runnable.clone().nav).ok(); + lsp_ext::Runnable::rebar3_shell(runnable, location, snap.workspace_root(file_id).into()) } pub(crate) fn buck2_run_runnable( @@ -756,9 +753,34 @@ pub(crate) fn code_lens( } } ProjectBuildData::Rebar(_) => { - let r = rebar3_test_runnable(snap, run.clone(), lens_config.run_coverage); + let ct_runnable = rebar3_ct_runnable(snap, run.clone()); + let shell_runnable = rebar3_shell_runnable(snap, run.clone()); + if lens_config.run_interactive { + match run.kind { + RunnableKind::Suite { .. } => { + let command = command::open_interactive( + &shell_runnable, + run_interactive_title, + ); + acc.push(lsp_types::CodeLens { + range: annotation_range, + command: Some(command), + data: None, + }); + } + RunnableKind::Test { .. } => { + let command = + command::run_interactive(&ct_runnable, run_interactive_title); + acc.push(lsp_types::CodeLens { + range: annotation_range, + command: Some(command), + data: None, + }); + } + } + } if lens_config.run { - let run_command = command::run_single(&r, run_title); + let run_command = command::run_single(&ct_runnable, run_title); acc.push(lsp_types::CodeLens { range: annotation_range, command: Some(run_command), @@ -766,12 +788,26 @@ pub(crate) fn code_lens( }); } if lens_config.debug { - let debug_command = command::debug_single(&r, debug_title); - acc.push(lsp_types::CodeLens { - range: annotation_range, - command: Some(debug_command), - data: None, - }); + match run.kind { + RunnableKind::Suite { .. } => { + let debug_command = + command::debug_interactive(&ct_runnable, debug_title); + acc.push(lsp_types::CodeLens { + range: annotation_range, + command: Some(debug_command), + data: None, + }); + } + RunnableKind::Test { .. } => { + let debug_command = + command::debug_single(&ct_runnable, debug_title); + acc.push(lsp_types::CodeLens { + range: annotation_range, + command: Some(debug_command), + data: None, + }); + } + } } } ProjectBuildData::Static(_) => {} diff --git a/editors/code/client/src/commands.ts b/editors/code/client/src/commands.ts index 56ca7e8547..04984626b3 100644 --- a/editors/code/client/src/commands.ts +++ b/editors/code/client/src/commands.ts @@ -12,6 +12,7 @@ import * as vscode from 'vscode'; import * as dapConfig from './dapConfig'; import * as edbDebugger from './debugger'; import { LanguageClient } from 'vscode-languageclient/node'; +import { outputChannel } from './logging'; export type Runnable = { label: string; @@ -32,6 +33,11 @@ export type CommonRunnableArgs = { workspaceRoot: string; }; +const log = outputChannel(); + +const EDB_SHELL_TITLE = "EDB Shell"; +const REBAR3_SHELL_TITLE = "elp: Rebar3"; + export function registerCommands(context: vscode.ExtensionContext, client: LanguageClient) { context.subscriptions.push( vscode.commands.registerCommand( @@ -43,7 +49,25 @@ export function registerCommands(context: vscode.ExtensionContext, client: Langu vscode.commands.registerCommand( 'elp.debugSingle', async (runnable: Runnable) => { - await debugSingle(runnable); + await debug(runnable, false); + }, + ), + vscode.commands.registerCommand( + 'elp.openInteractive', + async (runnable: Runnable) => { + await openInteractive(runnable); + }, + ), + vscode.commands.registerCommand( + 'elp.debugInteractive', + async (runnable: Runnable) => { + await debug(runnable, true); + }, + ), + vscode.commands.registerCommand( + 'elp.runInteractive', + async (runnable: Runnable) => { + await runInteractive(runnable); }, ), vscode.commands.registerCommand( @@ -56,6 +80,7 @@ export function registerCommands(context: vscode.ExtensionContext, client: Langu } export function runSingle(runnable: Runnable): Thenable { + log.appendLine(`Run single: ${runnable.kind}`); const task = createTask(runnable); task.group = vscode.TaskGroup.Build; task.presentationOptions = { @@ -67,6 +92,37 @@ export function runSingle(runnable: Runnable): Thenable { return vscode.tasks.executeTask(task); } +export async function runInteractive(runnable: Runnable): Promise { + const terminal = vscode.window.terminals.find( + terminal => terminal.name.includes(REBAR3_SHELL_TITLE) || terminal.name.includes(EDB_SHELL_TITLE), + ); + const run_cmd = `r3:do(\"ct ${runnable.args.args.join(" ")}\").`; + if (terminal) { + terminal.show(); + terminal.sendText(run_cmd); + } else { + await vscode.window + .showInformationMessage('No active REPL. Open one, first.', 'Open REPL') + .then(async selection => { + if (selection === 'Open REPL') { + await vscode.commands.executeCommand("elp.openInteractive"); + } + }); + } +} + +export function openInteractive(runnable: Runnable): Thenable { + const task = createTask(runnable); + task.group = vscode.TaskGroup.Build; + task.presentationOptions = { + reveal: vscode.TaskRevealKind.Always, + panel: vscode.TaskPanelKind.Dedicated, + clear: true, + }; + const command = [runnable.args.command, ...runnable.args.args].join(' '); + return vscode.tasks.executeTask(task); +} + export function createTask(runnable: Runnable): vscode.Task { const command = runnable.kind; const args = [runnable.args.command, ...runnable.args.args]; @@ -105,17 +161,22 @@ export function buildTask( return new vscode.Task(definition, vscode.TaskScope.Workspace, name, task_source, exec, []); } -export async function debugSingle(runnable: Runnable): Promise { +export async function debug(runnable: Runnable, interactive: boolean): Promise { + log.appendLine(`Debug: ${runnable.kind}, interactive: ${interactive}`); + const cmd = interactive + ? 'ok' + : `Agent = self(), spawn(fun() -> catch gen_server:call(Agent, {cmd, default, ct, \"${runnable.args.args.join(" ")}\"}, infinity), erlang:halt(0) end)`; const debugConfiguration = { type: edbDebugger.DEBUG_TYPE, name: 'Erlang EDB', request: 'launch', runInTerminal: { kind: "integrated", + title: EDB_SHELL_TITLE, cwd: "${workspaceFolder}", args: ["bash", "-c", "rebar3 as test shell --eval \"$EDB_DAP_NODE_INIT, $REBAR3_SHELL_CT_RUN_CMD\""], env: { - REBAR3_SHELL_CT_RUN_CMD: `Agent = self(), spawn(fun() -> catch gen_server:call(Agent, {cmd, default, ct, \"${runnable.args.args.join(" ")}\"}, infinity), erlang:halt(0) end)`, + REBAR3_SHELL_CT_RUN_CMD: cmd, "PATH": dapConfig.withErlangInstallationPath(), }, argsCanBeInterpretedByShell: false, @@ -126,5 +187,6 @@ export async function debugSingle(runnable: Runnable): Promise { timeout: 60, }, }; + log.appendLine(JSON.stringify(debugConfiguration, null, 2)); await vscode.debug.startDebugging(undefined, debugConfiguration); } diff --git a/editors/code/package.json b/editors/code/package.json index 62f7339aae..87ab7d9e3f 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -307,7 +307,7 @@ "type": "boolean" }, "elp.lens.debug.enable": { - "default": false, + "default": true, "markdownDescription": "Whether to show the `Debug` lenses. Only applies when\n`#elp.lens.enable#` is set.", "type": "boolean" }, @@ -332,7 +332,7 @@ "type": "boolean" }, "elp.lens.run.interactive.enable": { - "default": false, + "default": true, "markdownDescription": "Whether to show the `Run Interactive` lenses. Only applies when\n`#elp.lens.enable#` is set.", "type": "boolean" }, From 2edd91934fc75df8b7a313861b757d26833d1738 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Thu, 11 Sep 2025 08:15:04 -0700 Subject: [PATCH 024/314] Bump OSS VS Code extension for imminent release Summary: Bump the extension to `0.41.0`. Reviewed By: mapoulin Differential Revision: D82220520 fbshipit-source-id: b33f40d92919bdedfeee5fb4c65875d0c6159623 --- editors/code/package-lock.json | 4 ++-- editors/code/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json index 48f3d8bf01..98c19db4c7 100644 --- a/editors/code/package-lock.json +++ b/editors/code/package-lock.json @@ -1,12 +1,12 @@ { "name": "erlang-language-platform", - "version": "0.40.0", + "version": "0.41.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "erlang-language-platform", - "version": "0.40.0", + "version": "0.41.0", "hasInstallScript": true, "license": "Apache2", "devDependencies": { diff --git a/editors/code/package.json b/editors/code/package.json index 87ab7d9e3f..840dc20ae3 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -3,7 +3,7 @@ "description": "Erlang language server", "author": "Meta Platforms, Inc", "license": "Apache2", - "version": "0.40.0", + "version": "0.41.0", "icon": "images/elp-logo-color.png", "homepage": "https://whatsapp.github.io/erlang-language-platform/", "repository": { From 981d2546286ba020193a0c8e82dc256cf1df44e1 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Thu, 11 Sep 2025 08:45:47 -0700 Subject: [PATCH 025/314] Add tasks for common ELP dev actions Summary: VS Code allows project-specific tasks to be configured for a project. Add some ones for ELP development on an OD, so the various build flavours and clippy checks can be invoked. Reviewed By: TD5 Differential Revision: D82212928 fbshipit-source-id: daf2502db247ca60f216ec84a7bbae0f54f29225 --- .vscode/tasks.json | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..e3040f1a5f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,89 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "ELP: build (debug)", + "type": "shell", + "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh build", + "group": { + "kind": "build", + "is_default": true, + + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "ELP: build (release)", + "type": "shell", + "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh build --release", + "group": { + "kind": "build", + "is_default": true, + + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "ELP: build (release-thin)", + "type": "shell", + "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh build --profile release-thin --bins", + "group": { + "kind": "build", + "is_default": true, + + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "ELP: run clippy on workspace", + "type": "shell", + "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/clippy.sh --workspace --tests", + "group": { + "kind": "build", + "is_default": true, + + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "ELP: run clippy on workspace, apply fixes", + "type": "shell", + "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/clippy.sh --workspace --tests --fix", + "group": { + "kind": "build", + "is_default": true, + + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "ELP: run tests on workspace", + "type": "shell", + "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh test --workspace", + "group": { + "kind": "build", + "is_default": true, + + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + ] +} From 92a009df6363742c99e2377c8f66776d322d2925 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Fri, 12 Sep 2025 02:39:39 -0700 Subject: [PATCH 026/314] Make tasks.json work for internal and OSS usage Summary: Use relative paths to invoke the meta-specific commands, and provide alternatives using `cargo`, configured via Shipit Reviewed By: robertoaloi Differential Revision: D82296502 fbshipit-source-id: 854591848d4c5212cf0cd0eda57559acf5cc7411 --- .vscode/tasks.json | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e3040f1a5f..51f0340659 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,8 @@ { "label": "ELP: build (debug)", "type": "shell", - "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh build", + // @fb-only + "command": "cargo build", // @oss-only "group": { "kind": "build", "is_default": true, @@ -18,7 +19,8 @@ { "label": "ELP: build (release)", "type": "shell", - "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh build --release", + // @fb-only + "command": "cargo build --release", // @oss-only "group": { "kind": "build", "is_default": true, @@ -32,7 +34,8 @@ { "label": "ELP: build (release-thin)", "type": "shell", - "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh build --profile release-thin --bins", + // @fb-only + "command": "cargo build --profile release-thin --bins", // @oss-only "group": { "kind": "build", "is_default": true, @@ -46,7 +49,8 @@ { "label": "ELP: run clippy on workspace", "type": "shell", - "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/clippy.sh --workspace --tests", + // @fb-only + "command": "cargo clippy --workspace --tests", // @oss-only "group": { "kind": "build", "is_default": true, @@ -60,7 +64,8 @@ { "label": "ELP: run clippy on workspace, apply fixes", "type": "shell", - "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/clippy.sh --workspace --tests --fix", + // @fb-only + "command": "cargo clippy --workspace --tests --fix", // @oss-only "group": { "kind": "build", "is_default": true, @@ -74,7 +79,8 @@ { "label": "ELP: run tests on workspace", "type": "shell", - "command": "/data/sandcastle/boxes/fbsource/fbcode/whatsapp/elp/meta/cargo.sh test --workspace", + // @fb-only + "command": "cargo test --workspace", // @oss-only "group": { "kind": "build", "is_default": true, From 0f39e522aff987f3964462c93fa2a98c22c6ffa3 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 15 Sep 2025 07:57:01 -0700 Subject: [PATCH 027/314] Convert no_forgets_api linter to use the Linter trait Summary: Convert the `no_forgets_api` linter to the new trait. To enable the migration, ensure the `file_id` is available in the `is_match_valid` function. Reviewed By: jcpetruzza Differential Revision: D81593935 fbshipit-source-id: c00d5c7f5078ba04fb22c13238367ff1ebc91398 --- crates/ide/src/diagnostics.rs | 3 ++- crates/ide/src/diagnostics/atoms_exhaustion.rs | 1 + crates/ide/src/diagnostics/debugging_function.rs | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 22d8f7e133..d57e048b96 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -595,6 +595,7 @@ pub(crate) trait FunctionCallLinter: Linter { &self, _check_call_context: &CheckCallCtx<'_, ()>, _sema: &Semantic, + _file_id: FileId, ) -> Option { Some(Self::Context::default()) } @@ -643,7 +644,7 @@ impl FunctionCallDiagnostics for T { sema, def, &mfas, - &move |ctx| self.is_match_valid(&ctx, sema), + &move |ctx| self.is_match_valid(&ctx, sema, file_id), &move |ctx @ MatchCtx { sema, def_fb, .. }| { let range = ctx.range(&UseRange::NameOnly); if range.file_id == def.file.file_id { diff --git a/crates/ide/src/diagnostics/atoms_exhaustion.rs b/crates/ide/src/diagnostics/atoms_exhaustion.rs index 334bf86853..8977a67a29 100644 --- a/crates/ide/src/diagnostics/atoms_exhaustion.rs +++ b/crates/ide/src/diagnostics/atoms_exhaustion.rs @@ -63,6 +63,7 @@ impl FunctionCallLinter for AtomsExhaustionLinter { &self, context: &CheckCallCtx<'_, ()>, sema: &Semantic, + _file_id: FileId, ) -> Option { #[rustfmt::skip] // @fb-only diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index 2e8607ccb7..08acc426b8 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -61,6 +61,7 @@ impl FunctionCallLinter for NoDebuggingFunctionLinter { &self, context: &CheckCallCtx<'_, ()>, _sema: &Semantic, + _file_id: FileId, ) -> Option { let call_expr_id = context.parents.last().cloned(); Some(call_expr_id) From 2e30b64679b0035beac00d12cfab7840a4be2d26 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 15 Sep 2025 07:57:01 -0700 Subject: [PATCH 028/314] Rename is_match_valid into check_match Summary: Avoid the `is_*` pattern, given the function does not return a boolean (or optional boolean). Reviewed By: alanz Differential Revision: D82433078 fbshipit-source-id: d418c7c5e0c5f18d7de8403864d4b27f021c367d --- crates/ide/src/diagnostics.rs | 4 ++-- crates/ide/src/diagnostics/atoms_exhaustion.rs | 2 +- crates/ide/src/diagnostics/debugging_function.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index d57e048b96..df794e240f 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -591,7 +591,7 @@ pub(crate) trait FunctionCallLinter: Linter { // Custom check for the function call. Returning None for a given call skips processing. // By default all calls are included. // The callback returns a function that can be used in subsequent callbacks. - fn is_match_valid( + fn check_match( &self, _check_call_context: &CheckCallCtx<'_, ()>, _sema: &Semantic, @@ -644,7 +644,7 @@ impl FunctionCallDiagnostics for T { sema, def, &mfas, - &move |ctx| self.is_match_valid(&ctx, sema, file_id), + &move |ctx| self.check_match(&ctx, sema, file_id), &move |ctx @ MatchCtx { sema, def_fb, .. }| { let range = ctx.range(&UseRange::NameOnly); if range.file_id == def.file.file_id { diff --git a/crates/ide/src/diagnostics/atoms_exhaustion.rs b/crates/ide/src/diagnostics/atoms_exhaustion.rs index 8977a67a29..831666314f 100644 --- a/crates/ide/src/diagnostics/atoms_exhaustion.rs +++ b/crates/ide/src/diagnostics/atoms_exhaustion.rs @@ -59,7 +59,7 @@ impl FunctionCallLinter for AtomsExhaustionLinter { ] } - fn is_match_valid( + fn check_match( &self, context: &CheckCallCtx<'_, ()>, sema: &Semantic, diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index 08acc426b8..7bba9c9af3 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -57,7 +57,7 @@ impl FunctionCallLinter for NoDebuggingFunctionLinter { ] } - fn is_match_valid( + fn check_match( &self, context: &CheckCallCtx<'_, ()>, _sema: &Semantic, From 39da1592811596b3b48ec3aa5d2153586169db51 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 15 Sep 2025 07:57:01 -0700 Subject: [PATCH 029/314] Avoid passing sema and file_id since they are already part of the context Summary: As per title. Reviewed By: jcpetruzza, TheGeorge Differential Revision: D82433557 fbshipit-source-id: 03c299fdfc73ca3c46816b4eba659b4f5f3ededa --- crates/ide/src/diagnostics.rs | 9 ++------- crates/ide/src/diagnostics/atoms_exhaustion.rs | 8 ++------ crates/ide/src/diagnostics/debugging_function.rs | 7 +------ 3 files changed, 5 insertions(+), 19 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index df794e240f..e4d1583743 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -591,12 +591,7 @@ pub(crate) trait FunctionCallLinter: Linter { // Custom check for the function call. Returning None for a given call skips processing. // By default all calls are included. // The callback returns a function that can be used in subsequent callbacks. - fn check_match( - &self, - _check_call_context: &CheckCallCtx<'_, ()>, - _sema: &Semantic, - _file_id: FileId, - ) -> Option { + fn check_match(&self, _check_call_context: &CheckCallCtx<'_, ()>) -> Option { Some(Self::Context::default()) } @@ -644,7 +639,7 @@ impl FunctionCallDiagnostics for T { sema, def, &mfas, - &move |ctx| self.check_match(&ctx, sema, file_id), + &move |ctx| self.check_match(&ctx), &move |ctx @ MatchCtx { sema, def_fb, .. }| { let range = ctx.range(&UseRange::NameOnly); if range.file_id == def.file.file_id { diff --git a/crates/ide/src/diagnostics/atoms_exhaustion.rs b/crates/ide/src/diagnostics/atoms_exhaustion.rs index 831666314f..e2743087af 100644 --- a/crates/ide/src/diagnostics/atoms_exhaustion.rs +++ b/crates/ide/src/diagnostics/atoms_exhaustion.rs @@ -59,13 +59,9 @@ impl FunctionCallLinter for AtomsExhaustionLinter { ] } - fn check_match( - &self, - context: &CheckCallCtx<'_, ()>, - sema: &Semantic, - _file_id: FileId, - ) -> Option { + fn check_match(&self, context: &CheckCallCtx<'_, ()>) -> Option { #[rustfmt::skip] + // @fb-only // @fb-only // @fb-only let is_safe = false; // @oss-only diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index 7bba9c9af3..3a4bbe4959 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -57,12 +57,7 @@ impl FunctionCallLinter for NoDebuggingFunctionLinter { ] } - fn check_match( - &self, - context: &CheckCallCtx<'_, ()>, - _sema: &Semantic, - _file_id: FileId, - ) -> Option { + fn check_match(&self, context: &CheckCallCtx<'_, ()>) -> Option { let call_expr_id = context.parents.last().cloned(); Some(call_expr_id) } From 679ed1fb70c08ac585c38746d7f19334647c605c Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 15 Sep 2025 07:57:01 -0700 Subject: [PATCH 030/314] Return a &'static str for descriptions Summary: Avoid some unnecessary allocations. Reviewed By: TheGeorge Differential Revision: D82434334 fbshipit-source-id: b67baf4fa52d8f641e172d0bc5cdd4d9d7560cb1 --- crates/ide/src/diagnostics.rs | 4 ++-- crates/ide/src/diagnostics/atoms_exhaustion.rs | 4 ++-- .../ide/src/diagnostics/binary_string_to_sigil.rs | 4 ++-- .../src/diagnostics/could_be_a_string_literal.rs | 12 ++++++------ crates/ide/src/diagnostics/debugging_function.rs | 4 ++-- crates/ide/src/diagnostics/no_error_logger.rs | 4 ++-- crates/ide/src/diagnostics/no_garbage_collect.rs | 4 ++-- crates/ide/src/diagnostics/no_size.rs | 4 ++-- crates/ide/src/diagnostics/sets_version_2.rs | 4 ++-- .../diagnostics/unnecessary_fold_to_build_map.rs | 15 +++++++-------- .../unnecessary_map_to_list_in_comprehension.rs | 4 ++-- 11 files changed, 31 insertions(+), 32 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index e4d1583743..5756f62908 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -499,7 +499,7 @@ pub(crate) trait Linter { fn id(&self) -> DiagnosticCode; // A plain-text description for the linter. Displayed to the end user. - fn description(&self) -> String; + fn description(&self) -> &'static str; // The severity for the lint issue. It defaults to `Warning`. fn severity(&self) -> Severity { @@ -674,7 +674,7 @@ pub(crate) trait SsrPatternsLinter: Linter { /// Customize the description based on each matched pattern. /// If implemented, it overrides the value of the `description()`. - fn pattern_description(&self, _context: &Self::Context) -> String { + fn pattern_description(&self, _context: &Self::Context) -> &'static str { self.description() } diff --git a/crates/ide/src/diagnostics/atoms_exhaustion.rs b/crates/ide/src/diagnostics/atoms_exhaustion.rs index e2743087af..e576bab2f2 100644 --- a/crates/ide/src/diagnostics/atoms_exhaustion.rs +++ b/crates/ide/src/diagnostics/atoms_exhaustion.rs @@ -25,8 +25,8 @@ impl Linter for AtomsExhaustionLinter { fn id(&self) -> DiagnosticCode { DiagnosticCode::AtomsExhaustion } - fn description(&self) -> String { - "Risk of atoms exhaustion.".to_string() + fn description(&self) -> &'static str { + "Risk of atoms exhaustion." } fn should_process_generated_files(&self) -> bool { true diff --git a/crates/ide/src/diagnostics/binary_string_to_sigil.rs b/crates/ide/src/diagnostics/binary_string_to_sigil.rs index d3b6581b05..d87d206583 100644 --- a/crates/ide/src/diagnostics/binary_string_to_sigil.rs +++ b/crates/ide/src/diagnostics/binary_string_to_sigil.rs @@ -34,8 +34,8 @@ impl Linter for BinaryStringToSigilLinter { DiagnosticCode::BinaryStringToSigil } - fn description(&self) -> String { - "Binary string can be written using sigil syntax.".to_string() + fn description(&self) -> &'static str { + "Binary string can be written using sigil syntax." } fn severity(&self) -> Severity { diff --git a/crates/ide/src/diagnostics/could_be_a_string_literal.rs b/crates/ide/src/diagnostics/could_be_a_string_literal.rs index 0091f559f5..73b596e0c9 100644 --- a/crates/ide/src/diagnostics/could_be_a_string_literal.rs +++ b/crates/ide/src/diagnostics/could_be_a_string_literal.rs @@ -35,8 +35,8 @@ impl Linter for CouldBeAStringLiteralLinter { DiagnosticCode::CouldBeAStringLiteral } - fn description(&self) -> String { - "Could be rewritten as a literal.".to_string() + fn description(&self) -> &'static str { + "Could be rewritten as a literal." } fn severity(&self) -> Severity { @@ -95,11 +95,11 @@ impl SsrPatternsLinter for CouldBeAStringLiteralLinter { ] } - fn pattern_description(&self, context: &Self::Context) -> String { + fn pattern_description(&self, context: &Self::Context) -> &'static str { match context.to { - StringKind::List => "Could be rewritten as a string literal.".to_string(), - StringKind::Binary => "Could be rewritten as a binary string literal.".to_string(), - StringKind::Atom => "Could be rewritten as an atom literal.".to_string(), + StringKind::List => "Could be rewritten as a string literal.", + StringKind::Binary => "Could be rewritten as a binary string literal.", + StringKind::Atom => "Could be rewritten as an atom literal.", } } diff --git a/crates/ide/src/diagnostics/debugging_function.rs b/crates/ide/src/diagnostics/debugging_function.rs index 3a4bbe4959..cdeb147754 100644 --- a/crates/ide/src/diagnostics/debugging_function.rs +++ b/crates/ide/src/diagnostics/debugging_function.rs @@ -31,8 +31,8 @@ impl Linter for NoDebuggingFunctionLinter { fn id(&self) -> DiagnosticCode { DiagnosticCode::DebuggingFunction } - fn description(&self) -> String { - "Debugging functions should only be used during local debugging and usages should not be checked in.".to_string() + fn description(&self) -> &'static str { + "Debugging functions should only be used during local debugging and usages should not be checked in." } fn severity(&self) -> Severity { Severity::WeakWarning diff --git a/crates/ide/src/diagnostics/no_error_logger.rs b/crates/ide/src/diagnostics/no_error_logger.rs index d1a0da221a..b48ccf115c 100644 --- a/crates/ide/src/diagnostics/no_error_logger.rs +++ b/crates/ide/src/diagnostics/no_error_logger.rs @@ -20,8 +20,8 @@ impl Linter for NoErrorLoggerLinter { fn id(&self) -> DiagnosticCode { DiagnosticCode::NoErrorLogger } - fn description(&self) -> String { - "The `error_logger` module is deprecated.".to_string() + fn description(&self) -> &'static str { + "The `error_logger` module is deprecated." } fn severity(&self) -> Severity { Severity::Error diff --git a/crates/ide/src/diagnostics/no_garbage_collect.rs b/crates/ide/src/diagnostics/no_garbage_collect.rs index af2d74e3ee..e24ef7c9a9 100644 --- a/crates/ide/src/diagnostics/no_garbage_collect.rs +++ b/crates/ide/src/diagnostics/no_garbage_collect.rs @@ -20,8 +20,8 @@ impl Linter for NoGarbageCollectLinter { fn id(&self) -> DiagnosticCode { DiagnosticCode::NoGarbageCollect } - fn description(&self) -> String { - "Avoid forcing garbage collection.".to_string() + fn description(&self) -> &'static str { + "Avoid forcing garbage collection." } fn should_process_test_files(&self) -> bool { false diff --git a/crates/ide/src/diagnostics/no_size.rs b/crates/ide/src/diagnostics/no_size.rs index 5524a3655f..d298b49cfa 100644 --- a/crates/ide/src/diagnostics/no_size.rs +++ b/crates/ide/src/diagnostics/no_size.rs @@ -20,8 +20,8 @@ impl Linter for NoSizeLinter { fn id(&self) -> DiagnosticCode { DiagnosticCode::NoSize } - fn description(&self) -> String { - "Avoid using the `size/1` BIF.".to_string() + fn description(&self) -> &'static str { + "Avoid using the `size/1` BIF." } fn should_process_test_files(&self) -> bool { false diff --git a/crates/ide/src/diagnostics/sets_version_2.rs b/crates/ide/src/diagnostics/sets_version_2.rs index 44a228d717..dcd4c41a9f 100644 --- a/crates/ide/src/diagnostics/sets_version_2.rs +++ b/crates/ide/src/diagnostics/sets_version_2.rs @@ -20,8 +20,8 @@ impl Linter for SetsVersion2Linter { fn id(&self) -> DiagnosticCode { DiagnosticCode::SetsVersion2 } - fn description(&self) -> String { - "Prefer `[{version, 2}]` when constructing a set.".to_string() + fn description(&self) -> &'static str { + "Prefer `[{version, 2}]` when constructing a set." } fn should_process_test_files(&self) -> bool { false diff --git a/crates/ide/src/diagnostics/unnecessary_fold_to_build_map.rs b/crates/ide/src/diagnostics/unnecessary_fold_to_build_map.rs index b9ab3654e5..9fabb2f21d 100644 --- a/crates/ide/src/diagnostics/unnecessary_fold_to_build_map.rs +++ b/crates/ide/src/diagnostics/unnecessary_fold_to_build_map.rs @@ -45,8 +45,8 @@ impl Linter for UnnecessaryFoldToBuildMapLinter { DiagnosticCode::UnnecessaryFoldToBuildMapFromList } - fn description(&self) -> String { - "Unnecessary explicit fold to construct map.".to_string() + fn description(&self) -> &'static str { + "Unnecessary explicit fold to construct map." } fn severity(&self) -> Severity { @@ -80,12 +80,11 @@ impl SsrPatternsLinter for UnnecessaryFoldToBuildMapLinter { ] } - fn pattern_description(&self, context: &Self::Context) -> String { - let origin = match context { - PatternKind::FromList => "list".to_string(), - PatternKind::FromKeys => "keys".to_string(), - }; - format!("Unnecessary explicit fold to construct map from {origin}.") + fn pattern_description(&self, context: &Self::Context) -> &'static str { + match context { + PatternKind::FromList => "Unnecessary explicit fold to construct map from list.", + PatternKind::FromKeys => "Unnecessary explicit fold to construct map from keys.", + } } fn is_match_valid( diff --git a/crates/ide/src/diagnostics/unnecessary_map_to_list_in_comprehension.rs b/crates/ide/src/diagnostics/unnecessary_map_to_list_in_comprehension.rs index 70dd8c4b77..99859c225d 100644 --- a/crates/ide/src/diagnostics/unnecessary_map_to_list_in_comprehension.rs +++ b/crates/ide/src/diagnostics/unnecessary_map_to_list_in_comprehension.rs @@ -29,8 +29,8 @@ impl Linter for UnnecessaryMapToListInComprehensionLinter { DiagnosticCode::UnnecessaryMapToListInComprehension } - fn description(&self) -> String { - "Unnecessary intermediate list allocated.".to_string() + fn description(&self) -> &'static str { + "Unnecessary intermediate list allocated." } fn severity(&self) -> Severity { From 545452cc6d3a1588f5b94865d5f145d750f84348 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 15 Sep 2025 10:49:47 -0700 Subject: [PATCH 031/314] Provide an example of a poor translation from catch to try ... catch Reviewed By: acw224 Differential Revision: D82297776 fbshipit-source-id: 02aa5da78182369656c3343cee63d8a7faa7d97a --- website/docs/erlang-error-index/w/W0052.md | 73 ++++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/website/docs/erlang-error-index/w/W0052.md b/website/docs/erlang-error-index/w/W0052.md index 344f943137..a6e7b7837c 100644 --- a/website/docs/erlang-error-index/w/W0052.md +++ b/website/docs/erlang-error-index/w/W0052.md @@ -50,12 +50,12 @@ end. First notice that this always returns `other` if `api()` calls `throw(...)` because `foo = (catch throw(foo))`. We only get something interesting if `api()` -calls `exit(...)`. So, if your goal was to do something special when there is an -exception, `case catch` is definitely not what you want. +calls `exit(...)` or `error(...)`. So, if your goal was to do something special +when there is an exception, `case catch` is definitely not what you want. -If you only want to catch exits, it might work, unless the API legitimately -returns a tuple with first element being `'EXIT'`. So, this pattern is brittle -and does not generalize. Here is a bette way: +If you only want to catch exits and errors, it might work, unless the API +legitimately returns a tuple with first element being `'EXIT'`. So, this pattern +is brittle and does not generalize. Here is a better way: ```erlang try api() of @@ -97,6 +97,69 @@ end. It is more verbose but makes the interface and control flow clear. +## Preserving the old behaviour + +When converting a `catch` into a `try ... catch ... end`, keep in mind that we +may rely on the result of the `catch` expression itself. + +As an example, it would be ok to replace: + +```erlang +sum(X, Y) -> + catch could_crash(X), + X + Y. +``` + +With: + +```erlang +sum(X, Y) -> + try could_crash(X) + catch + _:_ -> + ok + end, + X + Y. +``` + +This is because the code inside the `catch` (then converted into a +`try ... catch ... end`) is only used for its _side effects_, and the result of +the `could_crash/1` function is ignored. + +Things are different in the following example: + +```erlang +main(X) -> + catch could_crash(X). + +could_crash(42) -> + throw(crash); +could_crash(_X) -> + ok. +``` + +One could mechanically convert the `catch` into a `try ... catch`: + +```erlang +main(X) -> + try could_crash(X) + catch _:_ -> + ok + end. + +could_crash(42) -> + throw(crash); +could_crash(_X) -> + ok. +``` + +This conversion could be potentially dangerous and should be rethinked, since +the old behaviour is not preserved. In fact, when calling `my_function(42)`, the +old code returns `crash`, while the new code returns `ok`. + +By examining the surrounding code you should be able to assess whether a +potentially backward incompatible behaviour is acceptable or not. + ## See also - [Errors and Error Handling](https://www.erlang.org/doc/system/errors.html) From a42d11847585832e8430d7263123deaed30bfdbc Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 15 Sep 2025 23:41:12 -0700 Subject: [PATCH 032/314] Convert undefined_function linter to use trait Summary: The previous version of the linter was conflating two distinct diagnostic codes into one. While this was more efficient in the sense that we would iterate once, the logic was quite hairy. This diff splits the old linter into two, making the logic and the intent more clear. Reviewed By: TD5 Differential Revision: D82443533 fbshipit-source-id: d4684c49fecbedace31e0a00b83b87da6aabf7fc --- ...e_elp_no_lint_specified_json_output.stdout | 2 +- .../parse_elp_no_lint_specified_output.stdout | 2 +- crates/ide/src/diagnostics.rs | 28 +- .../ide/src/diagnostics/undefined_function.rs | 333 +++--------------- .../src/diagnostics/unexported_function.rs | 250 +++++++++++++ 5 files changed, 329 insertions(+), 286 deletions(-) create mode 100644 crates/ide/src/diagnostics/unexported_function.rs diff --git a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout index a9e712e825..5c002e54de 100644 --- a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout +++ b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout @@ -1,9 +1,9 @@ {"path":"app_a/src/app_a.erl","line":5,"char":5,"code":"ELP","severity":"warning","name":"W0011 (application_get_env)","original":null,"replacement":null,"description":"module `app_a` belongs to app `app_a`, but reads env for `misc`\n\nFor more information see: /erlang-error-index/w/W0011"} {"path":"app_a/src/app_a.erl","line":1,"char":1,"code":"ELP","severity":"error","name":"W0012 (compile-warn-missing-spec)","original":null,"replacement":null,"description":"Please add \"-compile(warn_missing_spec_all).\" to the module. If exported functions are not all specced, they need to be specced.\n\nFor more information see: /erlang-error-index/w/W0012"} -{"path":"app_a/src/app_a.erl","line":13,"char":5,"code":"ELP","severity":"warning","name":"W0026 (unexported_function)","original":null,"replacement":null,"description":"Function 'app_a:baz/2' is not exported.\n\nFor more information see: /erlang-error-index/w/W0026"} {"path":"app_a/src/app_a.erl","line":9,"char":1,"code":"ELP","severity":"error","name":"P1700 (head_mismatch)","original":null,"replacement":null,"description":"head mismatch 'fooX' vs 'food'\n\nFor more information see: /erlang-error-index/p/P1700"} {"path":"app_a/src/app_a.erl","line":8,"char":7,"code":"ELP","severity":"warning","name":"W0018 (unexpected_semi_or_dot)","original":null,"replacement":null,"description":"Unexpected ';'\n\nFor more information see: /erlang-error-index/w/W0018"} {"path":"app_a/src/app_a.erl","line":1,"char":9,"code":"ELP","severity":"disabled","name":"W0046 (undocumented_module)","original":null,"replacement":null,"description":"The module is not documented.\n\nFor more information see: /erlang-error-index/w/W0046"} +{"path":"app_a/src/app_a.erl","line":13,"char":5,"code":"ELP","severity":"warning","name":"W0026 (unexported_function)","original":null,"replacement":null,"description":"Function 'app_a:baz/2' is not exported.\n\nFor more information see: /erlang-error-index/w/W0026"} {"path":"app_a/src/app_a.erl","line":12,"char":1,"code":"ELP","severity":"warning","name":"L1230 (L1230)","original":null,"replacement":null,"description":"function bar/0 is unused\n\nFor more information see: /erlang-error-index/l/L1230"} {"path":"app_a/src/app_a.erl","line":16,"char":1,"code":"ELP","severity":"warning","name":"L1230 (L1230)","original":null,"replacement":null,"description":"function baz/2 is unused\n\nFor more information see: /erlang-error-index/l/L1230"} {"path":"app_a/src/app_a_edoc.erl","line":1,"char":1,"code":"ELP","severity":"error","name":"W0012 (compile-warn-missing-spec)","original":null,"replacement":null,"description":"Please add \"-compile(warn_missing_spec_all).\" to the module. If exported functions are not all specced, they need to be specced.\n\nFor more information see: /erlang-error-index/w/W0012"} diff --git a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout index 714cc6bcd7..5099c34a65 100644 --- a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout +++ b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout @@ -3,10 +3,10 @@ Diagnostics reported in 7 modules: app_a: 8 4:4-4:34::[Warning] [W0011] module `app_a` belongs to app `app_a`, but reads env for `misc` 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. - 12:4-12:13::[Warning] [W0026] Function 'app_a:baz/2' is not exported. 8:0-8:4::[Error] [P1700] head mismatch 'fooX' vs 'food' 7:6-7:7::[Warning] [W0018] Unexpected ';' 0:8-0:13::[WeakWarning] [W0046] The module is not documented. + 12:4-12:13::[Warning] [W0026] Function 'app_a:baz/2' is not exported. 11:0-11:3::[Warning] [L1230] function bar/0 is unused 15:0-15:3::[Warning] [L1230] function baz/2 is unused app_a_edoc: 2 diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 5756f62908..52a14c58aa 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -8,6 +8,7 @@ * above-listed licenses. */ +use std::borrow::Cow; use std::collections::BTreeSet; use std::fmt; use std::sync::Arc; @@ -133,6 +134,7 @@ mod undefined_function; mod undefined_macro; mod undocumented_function; mod undocumented_module; +mod unexported_function; mod unnecessary_fold_to_build_map; mod unnecessary_map_from_list_around_comprehension; mod unnecessary_map_to_list_in_comprehension; @@ -583,6 +585,12 @@ pub(crate) trait FunctionCallLinter: Linter { /// Associated type - each linter defines its own type Context: Clone + fmt::Debug + PartialEq + Default; + /// Customize the description based on each match. + /// If implemented, it overrides the value of the `description()`. + fn match_description(&self, _context: &Self::Context) -> Cow<'_, str> { + Cow::Borrowed(self.description()) + } + // Specify the list of functions the linter should emit issues for fn matches_functions(&self) -> Vec { vec![] @@ -640,15 +648,20 @@ impl FunctionCallDiagnostics for T { def, &mfas, &move |ctx| self.check_match(&ctx), - &move |ctx @ MatchCtx { sema, def_fb, .. }| { + &move |ctx @ MatchCtx { + sema, + def_fb, + extra, + .. + }| { let range = ctx.range(&UseRange::NameOnly); if range.file_id == def.file.file_id { let fixes = self.fixes(&ctx, sema, file_id); - let mut diag = - Diagnostic::new(self.id(), self.description(), range.range) - .with_fixes(fixes) - .with_severity(severity) - .with_cli_severity(cli_severity); + let description = self.match_description(extra); + let mut diag = Diagnostic::new(self.id(), description, range.range) + .with_fixes(fixes) + .with_severity(severity) + .with_cli_severity(cli_severity); if self.can_be_suppressed() { diag = diag.with_ignore_fix(sema, def_fb.file_id()); }; @@ -1266,7 +1279,6 @@ pub fn diagnostics_descriptors<'a>() -> Vec<&'a DiagnosticDescriptor<'a>> { &missing_compile_warn_missing_spec::DESCRIPTOR, &dependent_header::DESCRIPTOR, &deprecated_function::DESCRIPTOR, - &undefined_function::DESCRIPTOR, &head_mismatch::DESCRIPTOR_SEMANTIC, &missing_separator::DESCRIPTOR, &cross_node_eval::DESCRIPTOR, @@ -1339,6 +1351,8 @@ const FUNCTION_CALL_LINTERS: &[&dyn FunctionCallDiagnostics] = &[ &no_error_logger::LINTER, &debugging_function::LINTER, &atoms_exhaustion::LINTER, + &undefined_function::LINTER, + &unexported_function::LINTER, ]; /// SSR pattern linters that use structural search and replace patterns diff --git a/crates/ide/src/diagnostics/undefined_function.rs b/crates/ide/src/diagnostics/undefined_function.rs index 9ae17f53a2..b22196d5a5 100644 --- a/crates/ide/src/diagnostics/undefined_function.rs +++ b/crates/ide/src/diagnostics/undefined_function.rs @@ -16,122 +16,79 @@ // Only fully qualified calls are reported by this diagnostic (e.g. `foo:bar/2`), since // calls to undefined local functions are already reported by the Erlang linter itself (L1227). -use elp_ide_assists::helpers; -use elp_ide_assists::helpers::ExportForm; -use elp_ide_db::elp_base_db::FileId; -use elp_ide_db::elp_base_db::FileRange; -use elp_ide_db::source_change::SourceChangeBuilder; +use std::borrow::Cow; + +use elp_syntax::SmolStr; use hir::Expr; -use hir::FunctionDef; use hir::Module; -use hir::NameArity; use hir::Semantic; use hir::known; -use super::Diagnostic; use super::DiagnosticCode; -use super::DiagnosticConditions; -use super::DiagnosticDescriptor; -use super::Severity; use crate::FunctionMatch; use crate::codemod_helpers::CheckCallCtx; -use crate::codemod_helpers::MatchCtx; -use crate::codemod_helpers::find_call_in_function; -// @fb-only -use crate::fix; +use crate::diagnostics::FunctionCallLinter; +use crate::diagnostics::Linter; +use crate::lazy_function_matches; -pub(crate) static DESCRIPTOR: DiagnosticDescriptor = DiagnosticDescriptor { - conditions: DiagnosticConditions { - experimental: false, - include_generated: true, - include_tests: true, - default_disabled: false, - }, - checker: &|diags, sema, file_id, _ext| { - undefined_function(diags, sema, file_id); - }, -}; +pub(crate) struct UndefinedFunctionLinter; -fn undefined_function(diagnostics: &mut Vec, sema: &Semantic, file_id: FileId) { - let check_for_unexported = true; // @oss-only - // @fb-only - sema.def_map_local(file_id) - .get_functions() - .for_each(|(_arity, def)| check_function(diagnostics, sema, def, check_for_unexported)); +impl Linter for UndefinedFunctionLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::UndefinedFunction + } + fn description(&self) -> &'static str { + "Function is undefined." + } + fn should_process_generated_files(&self) -> bool { + true + } } -fn check_function( - diags: &mut Vec, - sema: &Semantic, - def: &FunctionDef, - check_for_unexported: bool, -) { - let matcher = FunctionMatch::any(); - find_call_in_function( - diags, - sema, - def, - &[(&matcher, ())], - &move |CheckCallCtx { - target, - args, - in_clause: def_fb, - .. - }: CheckCallCtx<'_, ()>| { - let arity = args.arity(); - match target { - hir::CallTarget::Remote { module, name, .. } => { - let module = &def_fb[*module]; - let name = &def_fb[*name]; - if in_exclusion_list(sema, module, name, arity) - || sema - .resolve_module_expr(def_fb.file_id(), module) - .is_some_and(|module| is_automatically_added(sema, module, name, arity)) - { - None - } else { - let maybe_function_def = - target.resolve_call(arity, sema, def_fb.file_id(), &def_fb.body()); - let function_exists = maybe_function_def.is_some(); - let is_exported = maybe_function_def.clone().is_some_and(|fun_def| { - is_exported_function(fun_def.file.file_id, sema, &fun_def.name) - }); +impl FunctionCallLinter for UndefinedFunctionLinter { + type Context = Option; - if function_exists && (is_exported) { - None - } else { - target.label(arity, sema, &def_fb.body()).map(|label| { - ( - label.to_string(), - "".to_string(), - function_exists && !is_exported, - maybe_function_def, - ) - }) - } - } + fn matches_functions(&self) -> Vec { + lazy_function_matches![vec![FunctionMatch::any()]] + } + + fn match_description(&self, context: &Self::Context) -> Cow<'_, str> { + match context { + None => Cow::Borrowed(self.description()), + Some(function_name) => Cow::Owned(format!("Function '{function_name}' is undefined.")), + } + } + + fn check_match(&self, context: &CheckCallCtx<'_, ()>) -> Option { + match context.target { + hir::CallTarget::Remote { module, name, .. } => { + let sema = context.in_clause.sema; + let def_fb = context.in_clause; + let arity = context.args.arity(); + let module = &def_fb[*module]; + let name = &def_fb[*name]; + if in_exclusion_list(sema, module, name, arity) + || sema + .resolve_module_expr(def_fb.file_id(), module) + .is_some_and(|module| is_automatically_added(sema, module, name, arity)) + { + return None; + } + match context + .target + .resolve_call(arity, sema, def_fb.file_id(), &def_fb.body()) + { + Some(_) => None, + None => Some(context.target.label(arity, sema, &def_fb.body())), } - // Diagnostic L1227 already covers the case for local calls, so avoid double-reporting - hir::CallTarget::Local { .. } => None, } - }, - &move |ctx @ MatchCtx { sema, extra, .. }| { - make_diagnostic( - sema, - def.file.file_id, - ctx.range_mf_or_macro(), - &extra.0, - extra.2, - extra.3.clone(), - check_for_unexported, - ) - }, - ); + // Diagnostic L1227 already covers the case for local calls, so avoid double-reporting + hir::CallTarget::Local { .. } => None, + } + } } -fn is_exported_function(file_id: FileId, sema: &Semantic, name: &NameArity) -> bool { - sema.def_map(file_id).is_function_exported(name) -} +pub static LINTER: UndefinedFunctionLinter = UndefinedFunctionLinter; fn is_automatically_added(sema: &Semantic, module: Module, function: &Expr, arity: u32) -> bool { // If the module defines callbacks, {behaviour,behavior}_info are automatically defined @@ -158,69 +115,6 @@ fn in_exclusion_list(sema: &Semantic, module: &Expr, function: &Expr, arity: u32 || sema.is_atom_named(module, &known::thrift_parser) } -fn make_diagnostic( - sema: &Semantic, - file_id: FileId, - range: FileRange, - function_name: &str, - is_private: bool, - maybe_function_def: Option, - check_for_unexported: bool, -) -> Option { - let range = if range.file_id == file_id { - Some(range.range) - } else { - None - }?; - if is_private { - if check_for_unexported { - let maybe_fix = maybe_function_def.map(|function_def| { - let mut builder = SourceChangeBuilder::new(function_def.file.file_id); - helpers::ExportBuilder::new( - sema, - function_def.file.file_id, - ExportForm::Functions, - &[function_def.name], - &mut builder, - ) - .finish(); - - fix( - "export_function", - format!("Export the function `{function_name}`").as_str(), - builder.finish(), - range, - ) - }); - let mut diagnostic = Diagnostic::new( - DiagnosticCode::UnexportedFunction, - format!("Function '{function_name}' is not exported."), - range, - ) - .with_severity(Severity::Warning) - .with_ignore_fix(sema, file_id); - - maybe_fix.inspect(|fix| { - diagnostic.add_fix(fix.clone()); - }); - - Some(diagnostic) - } else { - None - } - } else { - Some( - Diagnostic::new( - DiagnosticCode::UndefinedFunction, - format!("Function '{function_name}' is undefined."), - range, - ) - .with_severity(Severity::Warning) - .with_ignore_fix(sema, file_id), - ) - } -} - #[cfg(test)] mod tests { @@ -229,7 +123,6 @@ mod tests { use crate::DiagnosticsConfig; use crate::tests::check_diagnostics_with_config; use crate::tests::check_fix; - use crate::tests::check_nth_fix; pub(crate) fn check_diagnostics(fixture: &str) { let config = DiagnosticsConfig::default().disable(elp_ide_db::DiagnosticCode::NoSize); @@ -338,60 +231,6 @@ mod tests { ) } - #[test] - fn test_private() { - check_diagnostics( - r#" -//- /src/main.erl - -module(main). - main() -> - dependency:exists(), - dependency:private(). -%% ^^^^^^^^^^^^^^^^^^ 💡 warning: Function 'dependency:private/0' is not exported. - exists() -> ok. -//- /src/dependency.erl - -module(dependency). - -export([exists/0]). - exists() -> ok. - private() -> ok. - "#, - ) - } - - #[test] - fn test_private_same_module() { - check_diagnostics( - r#" -//- /src/main.erl - -module(main). - main() -> - ?MODULE:private(), -%% ^^^^^^^^^^^^^^^ 💡 warning: Function 'main:private/0' is not exported. - main:private(). -%% ^^^^^^^^^^^^ 💡 warning: Function 'main:private/0' is not exported. - - private() -> ok. - "#, - ) - } - - #[test] - fn remote_call_to_header() { - check_diagnostics( - r#" -//- /src/main.erl --module(main). --include("header.hrl"). - -foo() -> main:bar(). -%% ^^^^^^^^ 💡 warning: Function 'main:bar/0' is not exported. - -//- /src/header.hrl - bar() -> ok. -"#, - ); - } - #[test] fn test_exclude_module_info() { check_diagnostics( @@ -515,66 +354,6 @@ exists() -> ok. ) } - #[test] - fn test_export_fix_ignore() { - check_fix( - r#" -//- /src/main.erl --module(main). - -main() -> - dep:exists(), - dep:pr~ivate(). - -//- /src/dep.erl --module(dep). --export([exists/0]). -exists() -> ok. -private() -> ok. -"#, - expect![[r#" --module(main). - -main() -> - dep:exists(), - % elp:ignore W0026 (unexported_function) - dep:private(). - -"#]], - ) - } - - #[test] - fn test_export_fix() { - check_nth_fix( - 1, - r#" -//- /src/main.erl --module(main). - -main() -> - dep:exists(), - dep:pr~ivate(). - -exists() -> ok. -//- /src/dep.erl --module(dep). --export([exists/0]). -exists() -> ok. -private() -> ok. -"#, - expect![[r#" --module(dep). --export([exists/0, private/0]). -exists() -> ok. -private() -> ok. -"#]], - DiagnosticsConfig::default().set_experimental(true), - &vec![], - crate::tests::IncludeCodeActionAssists::Yes, - ) - } - #[test] fn test_capture_fun() { check_diagnostics( diff --git a/crates/ide/src/diagnostics/unexported_function.rs b/crates/ide/src/diagnostics/unexported_function.rs new file mode 100644 index 0000000000..3d295b6c53 --- /dev/null +++ b/crates/ide/src/diagnostics/unexported_function.rs @@ -0,0 +1,250 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +// Diagnostic: unexported-function + +use std::borrow::Cow; + +use elp_ide_assists::Assist; +use elp_ide_assists::helpers; +use elp_ide_assists::helpers::ExportForm; +use elp_ide_db::elp_base_db::FileId; +use elp_ide_db::source_change::SourceChangeBuilder; +use elp_syntax::SmolStr; +use hir::FunctionDef; +use hir::Semantic; + +use super::DiagnosticCode; +use crate::FunctionMatch; +use crate::codemod_helpers::CheckCallCtx; +use crate::codemod_helpers::MatchCtx; +use crate::diagnostics::FunctionCallLinter; +use crate::diagnostics::Linter; +// @fb-only +use crate::fix; +use crate::lazy_function_matches; + +pub(crate) struct UnexportedFunctionLinter; + +impl Linter for UnexportedFunctionLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::UnexportedFunction + } + fn description(&self) -> &'static str { + "Function is not exported." + } + fn should_process_generated_files(&self) -> bool { + true + } + fn should_process_file_id(&self, sema: &Semantic, file_id: FileId) -> bool { + true // @oss-only + // @fb-only + } +} + +impl FunctionCallLinter for UnexportedFunctionLinter { + type Context = Option<(SmolStr, FunctionDef)>; + + fn matches_functions(&self) -> Vec { + lazy_function_matches![vec![FunctionMatch::any()]] + } + + fn check_match(&self, context: &CheckCallCtx<'_, ()>) -> Option { + match context.target { + hir::CallTarget::Remote { .. } => { + let def_fb = context.in_clause; + let arity = context.args.arity(); + let sema = context.in_clause.sema; + if let Some(fun_def) = + context + .target + .resolve_call(arity, sema, def_fb.file_id(), &def_fb.body()) + && !sema + .def_map(fun_def.file.file_id) + .is_function_exported(&fun_def.name) + { + let label = context.target.label(arity, sema, &def_fb.body())?; + return Some(Some((label, fun_def))); + } + } + hir::CallTarget::Local { .. } => (), + } + None + } + + fn match_description(&self, context: &Self::Context) -> Cow<'_, str> { + match context { + None => Cow::Borrowed(self.description()), + Some((label, _target_def)) => { + Cow::Owned(format!("Function '{label}' is not exported.")) + } + } + } + + fn fixes( + &self, + match_context: &MatchCtx, + sema: &Semantic, + _file_id: FileId, + ) -> Option> { + let (_label, target_def) = match_context.extra.as_ref()?; + let target_file_id = target_def.file.file_id; + let mut builder = SourceChangeBuilder::new(target_file_id); + helpers::ExportBuilder::new( + sema, + target_file_id, + ExportForm::Functions, + std::slice::from_ref(&target_def.name), + &mut builder, + ) + .finish(); + let function_name = &target_def.name; + Some(vec![fix( + "export_function", + format!("Export the function `{function_name}`").as_str(), + builder.finish(), + match_context.range.range, + )]) + } +} +pub static LINTER: UnexportedFunctionLinter = UnexportedFunctionLinter; + +#[cfg(test)] +mod tests { + + use expect_test::expect; + + use crate::DiagnosticsConfig; + use crate::tests::check_diagnostics_with_config; + use crate::tests::check_nth_fix; + + pub(crate) fn check_diagnostics(fixture: &str) { + let config = DiagnosticsConfig::default().disable(elp_ide_db::DiagnosticCode::NoSize); + check_diagnostics_with_config(config, fixture) + } + + #[test] + fn test_private() { + check_diagnostics( + r#" +//- /src/main.erl + -module(main). + main() -> + dependency:exists(), + dependency:private(). +%% ^^^^^^^^^^^^^^^^^^ 💡 warning: Function 'dependency:private/0' is not exported. + exists() -> ok. +//- /src/dependency.erl + -module(dependency). + -export([exists/0]). + exists() -> ok. + private() -> ok. + "#, + ) + } + + #[test] + fn test_private_same_module() { + check_diagnostics( + r#" +//- /src/main.erl + -module(main). + main() -> + ?MODULE:private(), +%% ^^^^^^^^^^^^^^^ 💡 warning: Function 'main:private/0' is not exported. + main:private(). +%% ^^^^^^^^^^^^ 💡 warning: Function 'main:private/0' is not exported. + + private() -> ok. + "#, + ) + } + + #[test] + fn remote_call_to_header() { + check_diagnostics( + r#" +//- /src/main.erl +-module(main). +-include("header.hrl"). + +foo() -> main:bar(). +%% ^^^^^^^^ 💡 warning: Function 'main:bar/0' is not exported. + +//- /src/header.hrl + bar() -> ok. +"#, + ); + } + + #[test] + fn test_export_fix_ignore() { + check_nth_fix( + 1, + r#" +//- /src/main.erl +-module(main). + +main() -> + dep:exists(), + dep:pr~ivate(). + +//- /src/dep.erl +-module(dep). +-export([exists/0]). +exists() -> ok. +private() -> ok. +"#, + expect![[r#" +-module(main). + +main() -> + dep:exists(), + % elp:ignore W0026 (unexported_function) + dep:private(). + +"#]], + DiagnosticsConfig::default().set_experimental(true), + &vec![], + crate::tests::IncludeCodeActionAssists::Yes, + ) + } + + #[test] + fn test_export_fix() { + check_nth_fix( + 0, + r#" +//- /src/main.erl +-module(main). + +main() -> + dep:exists(), + dep:pr~ivate(). + +exists() -> ok. +//- /src/dep.erl +-module(dep). +-export([exists/0]). +exists() -> ok. +private() -> ok. +"#, + expect![[r#" +-module(dep). +-export([exists/0, private/0]). +exists() -> ok. +private() -> ok. +"#]], + DiagnosticsConfig::default().set_experimental(true), + &vec![], + crate::tests::IncludeCodeActionAssists::Yes, + ) + } +} From 8d82b307edae42160f69554e6834b17885f626de Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 16 Sep 2025 05:36:26 -0700 Subject: [PATCH 033/314] Allow function call linters to specify a list of matches to exclude from processing Summary: Expand the `find_call_in_function` capabilities to allow excluding specific functions by using a vector of *FunctionMatch*. Then, add an optional `excludes_functions` method to the `FunctionCallLinter` trait. Use the new method to simplify the code in `undefined_function` to specify the exclusion list at a higher level. This is in preparation of making the list of exclusions configurable via the TOML file, which will happen next. Reviewed By: TD5 Differential Revision: D82526373 fbshipit-source-id: 1f07c8495655103d479dc76b6837b3968edb7dba --- crates/ide/src/codemod_helpers.rs | 11 ++++++++- crates/ide/src/diagnostics.rs | 9 ++++++++ crates/ide/src/diagnostics/application_env.rs | 1 + crates/ide/src/diagnostics/cross_node_eval.rs | 1 + crates/ide/src/diagnostics/meck.rs | 1 + crates/ide/src/diagnostics/replace_call.rs | 1 + .../ide/src/diagnostics/undefined_function.rs | 23 ++++++++++--------- 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/ide/src/codemod_helpers.rs b/crates/ide/src/codemod_helpers.rs index ea11b664ae..dc0b57b97e 100644 --- a/crates/ide/src/codemod_helpers.rs +++ b/crates/ide/src/codemod_helpers.rs @@ -522,11 +522,13 @@ pub(crate) fn find_call_in_function( sema: &Semantic, def: &FunctionDef, mfas: &[(&FunctionMatch, CallCtx)], + excluded_mfas: &[(&FunctionMatch, CallCtx)], check_call: CheckCall, make: Make, ) -> Option<()> { let def_fb = def.in_function_body(sema, def); let matcher = FunctionMatcher::new(mfas); + let excluded_matcher = FunctionMatcher::new(excluded_mfas); def_fb.clone().fold_function( Strategy { macros: MacroStrategy::ExpandButIncludeMacroCall, @@ -544,7 +546,13 @@ pub(crate) fn find_call_in_function( }, AnyExpr::Expr(Expr::Call { target, args }) => Some((target, Args::Args(args))), _ => None, - } && let Some((mfa, t)) = matcher.get_match( + } && let None = excluded_matcher.get_match( + &target, + args.arity(), + Some(&args.as_vec()), + sema, + &def_fb.body(clause_id), + ) && let Some((mfa, t)) = matcher.get_match( &target, args.arity(), Some(&args.as_vec()), @@ -664,6 +672,7 @@ mod tests { sema, def, &mfas, + &[], &move |_ctx| Some("Diagnostic Message"), &move |ctx @ MatchCtx { sema, diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 52a14c58aa..1ec248e358 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -596,6 +596,11 @@ pub(crate) trait FunctionCallLinter: Linter { vec![] } + // Specify a list of functions the linter should exclude from the check. + fn excludes_functions(&self) -> Vec { + vec![] + } + // Custom check for the function call. Returning None for a given call skips processing. // By default all calls are included. // The callback returns a function that can be used in subsequent callbacks. @@ -638,7 +643,10 @@ impl FunctionCallDiagnostics for T { ) -> Vec { let mut diagnostics = Vec::new(); let matches = self.matches_functions(); + let excluded_matches = self.excludes_functions(); let mfas: Vec<(&FunctionMatch, ())> = matches.iter().map(|m| (m, ())).collect(); + let excluded_mfas: Vec<(&FunctionMatch, ())> = + excluded_matches.iter().map(|m| (m, ())).collect(); sema.def_map_local(file_id) .get_functions() .for_each(|(_, def)| { @@ -647,6 +655,7 @@ impl FunctionCallDiagnostics for T { sema, def, &mfas, + &excluded_mfas, &move |ctx| self.check_match(&ctx), &move |ctx @ MatchCtx { sema, diff --git a/crates/ide/src/diagnostics/application_env.rs b/crates/ide/src/diagnostics/application_env.rs index fd12b7af89..dac88b260c 100644 --- a/crates/ide/src/diagnostics/application_env.rs +++ b/crates/ide/src/diagnostics/application_env.rs @@ -135,6 +135,7 @@ fn process_badmatches( sema, def, mfas, + &[], &move |CheckCallCtx { t, args, in_clause, .. }: CheckCallCtx<'_, &BadEnvCallAction>| match t { diff --git a/crates/ide/src/diagnostics/cross_node_eval.rs b/crates/ide/src/diagnostics/cross_node_eval.rs index 413f520652..36ae2cb383 100644 --- a/crates/ide/src/diagnostics/cross_node_eval.rs +++ b/crates/ide/src/diagnostics/cross_node_eval.rs @@ -83,6 +83,7 @@ fn process_badmatches( sema, def, bad, + &[], &move |ctx| match ctx.t { Some(m) => Some(*m), None => Some(r#"Production code must not use cross node eval (e.g. `rpc:call()`)"#), diff --git a/crates/ide/src/diagnostics/meck.rs b/crates/ide/src/diagnostics/meck.rs index 73bc3a2180..fb79f39b57 100644 --- a/crates/ide/src/diagnostics/meck.rs +++ b/crates/ide/src/diagnostics/meck.rs @@ -65,6 +65,7 @@ pub(crate) fn check_function(diags: &mut Vec, sema: &Semantic, def: sema, def, &[(&FunctionMatch::mf("meck", "new"), ())], + &[], &move |CheckCallCtx { args, in_clause: def_fb, diff --git a/crates/ide/src/diagnostics/replace_call.rs b/crates/ide/src/diagnostics/replace_call.rs index 80c0ec3e97..e27592dd3a 100644 --- a/crates/ide/src/diagnostics/replace_call.rs +++ b/crates/ide/src/diagnostics/replace_call.rs @@ -82,6 +82,7 @@ pub fn replace_call_site_if_args_match( sema, def, &[(fm, ())], + &[], &args_match, &move |MatchCtx { sema, diff --git a/crates/ide/src/diagnostics/undefined_function.rs b/crates/ide/src/diagnostics/undefined_function.rs index b22196d5a5..a7dca42236 100644 --- a/crates/ide/src/diagnostics/undefined_function.rs +++ b/crates/ide/src/diagnostics/undefined_function.rs @@ -52,6 +52,17 @@ impl FunctionCallLinter for UndefinedFunctionLinter { lazy_function_matches![vec![FunctionMatch::any()]] } + // T237551085: Once linters can take a custom configuration via the TOML files, move this to a config + fn excludes_functions(&self) -> Vec { + lazy_function_matches![vec![ + FunctionMatch::m("lager"), + FunctionMatch::m("graphql_scanner"), + FunctionMatch::m("graphql_parser"), + FunctionMatch::m("thrift_scanner"), + FunctionMatch::m("thrift_parser"), + ]] + } + fn match_description(&self, context: &Self::Context) -> Cow<'_, str> { match context { None => Cow::Borrowed(self.description()), @@ -67,7 +78,7 @@ impl FunctionCallLinter for UndefinedFunctionLinter { let arity = context.args.arity(); let module = &def_fb[*module]; let name = &def_fb[*name]; - if in_exclusion_list(sema, module, name, arity) + if sema.is_atom_named(name, &known::module_info) && (arity == 0 || arity == 1) || sema .resolve_module_expr(def_fb.file_id(), module) .is_some_and(|module| is_automatically_added(sema, module, name, arity)) @@ -105,16 +116,6 @@ fn is_automatically_added(sema: &Semantic, module: Module, function: &Expr, arit function_name_is_behaviour_info && arity == 1 && module_has_callbacks_defined } -// T237551085: Once linters can take a custom configuration via the TOML files, move this to a config -fn in_exclusion_list(sema: &Semantic, module: &Expr, function: &Expr, arity: u32) -> bool { - sema.is_atom_named(function, &known::module_info) && (arity == 0 || arity == 1) - || sema.is_atom_named(module, &known::lager) - || sema.is_atom_named(module, &known::graphql_scanner) - || sema.is_atom_named(module, &known::graphql_parser) - || sema.is_atom_named(module, &known::thrift_scanner) - || sema.is_atom_named(module, &known::thrift_parser) -} - #[cfg(test)] mod tests { From 314dbd68ca5a4604eb636a3117b15880f91d1b02 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 16 Sep 2025 10:00:51 -0700 Subject: [PATCH 034/314] Allow excluding function call matches via config Summary: Add an `exclude` config option for linters implementing the `FunctionCallLinter` trait, allowing to exclude matches via config. Since the implementation uses `lazy_static` behind the scenes, this is only applied after a server restart. This will later be used to avoid hard-coding the exclusion list for the `undefined_functions` linter. For the time being, I'm using the default deserialization, so that one can write: ``` [linters.undefined_function] exclude = [ {type = "MFA", mfa = "my:function/2"} ] ``` This can be later refined not to require the boilerplate from users, but auto-detect the M, F or MFA. Reviewed By: alanz Differential Revision: D82535853 fbshipit-source-id: fe7bd54c0f1e6c803a6bca16b8951e27ac4dcc58 --- crates/ide/src/diagnostics.rs | 87 ++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 1ec248e358..c7dfcbab2f 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -630,6 +630,7 @@ pub(crate) trait FunctionCallDiagnostics: Linter { file_id: FileId, severity: Severity, cli_severity: Severity, + config: &FunctionCallLinterConfig, ) -> Vec; } @@ -640,13 +641,18 @@ impl FunctionCallDiagnostics for T { file_id: FileId, severity: Severity, cli_severity: Severity, + config: &FunctionCallLinterConfig, ) -> Vec { let mut diagnostics = Vec::new(); let matches = self.matches_functions(); let excluded_matches = self.excludes_functions(); + let excluded_matches_from_config = &config.exclude.clone().unwrap_or_default(); let mfas: Vec<(&FunctionMatch, ())> = matches.iter().map(|m| (m, ())).collect(); - let excluded_mfas: Vec<(&FunctionMatch, ())> = - excluded_matches.iter().map(|m| (m, ())).collect(); + let excluded_mfas: Vec<(&FunctionMatch, ())> = excluded_matches + .iter() + .chain(excluded_matches_from_config) + .map(|m| (m, ())) + .collect(); sema.def_map_local(file_id) .get_functions() .for_each(|(_, def)| { @@ -749,6 +755,7 @@ pub(crate) trait SsrPatternsDiagnostics: Linter { file_id: FileId, severity: Severity, cli_severity: Severity, + linter_config: &SsrPatternsLinterConfig, ) -> Vec; } @@ -759,6 +766,7 @@ impl SsrPatternsDiagnostics for T { file_id: FileId, severity: Severity, cli_severity: Severity, + _linter_config: &SsrPatternsLinterConfig, ) -> Vec { let mut res = Vec::new(); for (pattern, context) in self.patterns() { @@ -1025,6 +1033,26 @@ impl LintConfig { pub fn get_experimental_override(&self, diagnostic_code: &DiagnosticCode) -> Option { self.linters.get(diagnostic_code)?.experimental } + + pub fn get_function_call_linter_config( + &self, + diagnostic_code: &DiagnosticCode, + ) -> Option { + match self.linters.get(diagnostic_code)?.config.clone()? { + LinterTraitConfig::FunctionCallLinterConfig(config) => Some(config), + _ => None, + } + } + + pub fn get_ssr_patterns_linter_config( + &self, + diagnostic_code: &DiagnosticCode, + ) -> Option { + match self.linters.get(diagnostic_code)?.config.clone()? { + LinterTraitConfig::SsrPatternsLinterConfig(config) => Some(config), + _ => None, + } + } } // --------------------------------------------------------------------- @@ -1043,14 +1071,30 @@ pub struct LintConfig { pub linters: FxHashMap, } -/// Configuration for a specific linter that allows overriding default settings +#[derive(Deserialize, Serialize, Default, Debug, Clone)] +pub struct FunctionCallLinterConfig { + exclude: Option>, +} + +#[derive(Deserialize, Serialize, Default, Debug, Clone)] +pub struct SsrPatternsLinterConfig {} + #[derive(Deserialize, Serialize, Debug, Clone)] -#[derive(Default)] +#[serde(untagged)] +pub enum LinterTraitConfig { + FunctionCallLinterConfig(FunctionCallLinterConfig), + SsrPatternsLinterConfig(SsrPatternsLinterConfig), +} + +/// Configuration for a specific linter that allows overriding default settings +#[derive(Deserialize, Serialize, Debug, Clone, Default)] pub struct LinterConfig { pub severity: Option, pub include_tests: Option, pub include_generated: Option, pub experimental: Option, + #[serde(flatten)] + pub config: Option, } impl<'de> Deserialize<'de> for Severity { @@ -1430,12 +1474,37 @@ fn diagnostics_from_linters( }; match l { DiagnosticLinter::FunctionCall(function_linter) => { - let diagnostics = - function_linter.diagnostics(sema, file_id, severity, cli_severity); + let linter_config = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_function_call_linter_config(&linter.id()) + .unwrap_or_else(FunctionCallLinterConfig::default) + } else { + FunctionCallLinterConfig::default() + }; + let diagnostics = function_linter.diagnostics( + sema, + file_id, + severity, + cli_severity, + &linter_config, + ); res.extend(diagnostics); } DiagnosticLinter::SsrPatterns(ssr_linter) => { - let diagnostics = ssr_linter.diagnostics(sema, file_id, severity, cli_severity); + let linter_config = if let Some(lint_config) = config.lint_config.as_ref() { + lint_config + .get_ssr_patterns_linter_config(&linter.id()) + .unwrap_or_else(SsrPatternsLinterConfig::default) + } else { + SsrPatternsLinterConfig::default() + }; + let diagnostics = ssr_linter.diagnostics( + sema, + file_id, + severity, + cli_severity, + &linter_config, + ); res.extend(diagnostics); } } @@ -3279,6 +3348,7 @@ baz(1)->4. include_tests: None, include_generated: None, experimental: None, + config: None, }, ); @@ -3319,6 +3389,7 @@ baz(1)->4. include_tests: Some(true), include_generated: None, experimental: None, + config: None, }, ); @@ -3358,6 +3429,7 @@ baz(1)->4. include_tests: None, include_generated: Some(true), experimental: None, + config: None, }, ); @@ -3398,6 +3470,7 @@ baz(1)->4. include_tests: None, include_generated: None, experimental: Some(true), + config: None, }, ); From 1185d992f0df7004a4bd9837a8d2d24870936f62 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Tue, 16 Sep 2025 10:00:51 -0700 Subject: [PATCH 035/314] Specify exclusion list for undefined_function linter via config Reviewed By: alanz Differential Revision: D82543237 fbshipit-source-id: efd7b272aef7386f0615272922090bdb5306535a --- crates/ide/src/diagnostics/undefined_function.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/ide/src/diagnostics/undefined_function.rs b/crates/ide/src/diagnostics/undefined_function.rs index a7dca42236..2584e95896 100644 --- a/crates/ide/src/diagnostics/undefined_function.rs +++ b/crates/ide/src/diagnostics/undefined_function.rs @@ -52,14 +52,9 @@ impl FunctionCallLinter for UndefinedFunctionLinter { lazy_function_matches![vec![FunctionMatch::any()]] } - // T237551085: Once linters can take a custom configuration via the TOML files, move this to a config fn excludes_functions(&self) -> Vec { lazy_function_matches![vec![ - FunctionMatch::m("lager"), - FunctionMatch::m("graphql_scanner"), - FunctionMatch::m("graphql_parser"), - FunctionMatch::m("thrift_scanner"), - FunctionMatch::m("thrift_parser"), + FunctionMatch::m("lager"), // Lager functions are produced by parse transforms ]] } From ee888495e52e2080df105e9a330b1da3acf2c530 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 17 Sep 2025 01:38:03 -0700 Subject: [PATCH 036/314] Only show hover link to docs if the function has docs Summary: There is no point in showing a link if we know the pointed module/function has no docs. Reviewed By: alanz Differential Revision: D82544032 fbshipit-source-id: 27f5bf58c18afb44d57b6a1628e3a6383f77483e --- crates/hir/src/module_data.rs | 13 +++++++++++++ crates/ide/src/diagnostics/undocumented_function.rs | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/hir/src/module_data.rs b/crates/hir/src/module_data.rs index b24aa5df73..477637b775 100644 --- a/crates/hir/src/module_data.rs +++ b/crates/hir/src/module_data.rs @@ -113,6 +113,11 @@ impl Module { pub fn is_in_otp(&self, db: &dyn DefDatabase) -> bool { is_in_otp(self.file.file_id, db) } + + pub fn has_moduledoc(&self, db: &dyn DefDatabase) -> bool { + let forms = db.file_form_list(self.file.file_id); + forms.moduledoc_attributes().next().is_some() + } } #[derive(Clone, PartialEq, Eq, Debug)] @@ -351,6 +356,14 @@ impl FunctionDef { pub fn code_complexity(&self, sema: &Semantic, score_cap: Option) -> CodeComplexity { code_complexity::compute(sema, self, score_cap) } + + pub fn has_doc_attribute(&self) -> bool { + self.doc_id.is_some() + } + + pub fn has_doc_attribute_metadata(&self) -> bool { + self.doc_metadata_id.is_some() + } } fn doc_metadata_params(map: &MapExpr) -> Option { diff --git a/crates/ide/src/diagnostics/undocumented_function.rs b/crates/ide/src/diagnostics/undocumented_function.rs index 8e67dae85f..353e79fee3 100644 --- a/crates/ide/src/diagnostics/undocumented_function.rs +++ b/crates/ide/src/diagnostics/undocumented_function.rs @@ -93,8 +93,8 @@ fn check_function( callbacks: &FxHashSet, ) { if function_should_be_checked(sema, def, callbacks) - && def.doc_id.is_none() - && def.doc_metadata_id.is_none() + && !def.has_doc_attribute() + && !def.has_doc_attribute_metadata() && def.edoc_comments(sema.db).is_none() && let Some(name_range) = def.name_range(sema.db) { From cedda2811a9b0737108502d7d3d1d12139d03640 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Wed, 17 Sep 2025 02:51:58 -0700 Subject: [PATCH 037/314] buck2: do not provide deps_targets to elp.bxl Summary: The `--deps_targets` CLI argument is no longer used in [`elp.bxl`](https://fburl.com/code/8fbkds7w). We remove it in ELP, so it can later be removed from `elp.bxl` Reviewed By: TD5 Differential Revision: D82547631 fbshipit-source-id: c59400bd8ff126871031729bf9a59fcd4854dd3e --- crates/project_model/src/buck.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/project_model/src/buck.rs b/crates/project_model/src/buck.rs index 5d4d68265a..445bc61496 100644 --- a/crates/project_model/src/buck.rs +++ b/crates/project_model/src/buck.rs @@ -729,10 +729,6 @@ pub fn query_buck_targets_bxl( targets.push("--included_targets"); targets.push(target); } - for deps_target in &buck_config.deps_targets { - targets.push("--deps_targets"); - targets.push(deps_target); - } let build_args = if build == &BuckQueryConfig::BuildGeneratedCode { vec!["--build_generated_code", "true"] } else { From a849eaf3cda9290f696540199dfeded30963a8cb Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Wed, 17 Sep 2025 02:51:58 -0700 Subject: [PATCH 038/314] buck2: Remove BuckTargetOrigin::Prelude Summary: [elp.bxl](https://fburl.com/code/euy3qis2) no longer reports an origin of type "prelude", so remove it with ELP. Reviewed By: TD5 Differential Revision: D82548892 fbshipit-source-id: 5d69942503faefd530d34d0c4b3591a671f10e56 --- crates/project_model/src/buck.rs | 37 +++++++------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/crates/project_model/src/buck.rs b/crates/project_model/src/buck.rs index 445bc61496..d328a8669e 100644 --- a/crates/project_model/src/buck.rs +++ b/crates/project_model/src/buck.rs @@ -369,7 +369,6 @@ enum BuckTargetOrigin { #[default] App, Dep, - Prelude, } // Serde serialization via String @@ -378,7 +377,6 @@ impl From for String { match val { BuckTargetOrigin::App => "app".to_string(), BuckTargetOrigin::Dep => "dep".to_string(), - BuckTargetOrigin::Prelude => "prelude".to_string(), } } } @@ -398,7 +396,6 @@ impl TryFrom<&str> for BuckTargetOrigin { match value { "app" => Ok(BuckTargetOrigin::App), "dep" => Ok(BuckTargetOrigin::Dep), - "prelude" => Ok(BuckTargetOrigin::Prelude), _ => Err(format!( "bad origin value '{value}', expected 'app', 'dep', or 'prelude'." )), @@ -533,16 +530,14 @@ fn load_buck_targets_bxl( let mut used_deps = FxHashSet::default(); for (name, buck_target) in &buck_targets { - if buck_target.origin != BuckTargetOrigin::Prelude - && let Ok(target) = make_buck_target( - root, - name, - buck_target, - buck_config.build_deps, - &mut dep_path, - &mut target_info, - ) - { + if let Ok(target) = make_buck_target( + root, + name, + buck_target, + buck_config.build_deps, + &mut dep_path, + &mut target_info, + ) { for target_name in &target.apps { used_deps.insert(target_name.clone()); } @@ -552,22 +547,6 @@ fn load_buck_targets_bxl( target_info.targets.insert(name.clone(), target); } } - // Insert used prelude values too - for name in &used_deps { - if let Some(buck_target) = &buck_targets.get(name) - && buck_target.origin == BuckTargetOrigin::Prelude - && let Ok(target) = make_buck_target( - root, - name, - buck_target, - buck_config.build_deps, - &mut dep_path, - &mut target_info, - ) - { - target_info.targets.insert(name.clone(), target); - } - } Ok(target_info) } From 686d42f782b39322d47cf751c471ebf47191f355 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Wed, 17 Sep 2025 07:49:31 -0700 Subject: [PATCH 039/314] Add new option to mark Erlang Service warnings as errors Summary: An Erlang project can be compiled with the [`warnings_as_errors`](https://www.erlang.org/doc/apps/compiler/compile.html#file/2) option, which causes warnings to be treated as errors. ELP does not respect this option when it comes to diagnostics produced by the Erlang Service (the Erlang sidecar running next to ELP), which can create confusion in the user: if `warnings_as_errors` is specified, ELP would only raise a warning (both in the IDE and in the CLI), but the project would fail compilation. Here we add a new configuration section for the `[erlang_service]`, where the option can be explicitly passed. Later on, we could automatically expand this mechanism to auto-extract the option from either a rebar3 or a buck2 project. For now, that's considered out of scope. Since the Erlang Service validates Erlang modules using a fork of the [`erl_lint`](https://github.com/erlang/otp/blob/master/lib/stdlib/src/erl_lint.erl) linter, which does not respect the `warnings_as_errors` option, we tweak the result on ELP side. We also document the new config section. Reviewed By: alanz Differential Revision: D82628908 fbshipit-source-id: f3ced1cf333b463657cbc13113129bb15533a9d6 --- crates/elp/src/bin/lint_cli.rs | 10 ++++++ crates/elp/src/bin/main.rs | 15 ++++++++ crates/elp/src/lib.rs | 5 +++ .../test/linter/warnings_as_errors.stdout | 34 +++++++++++++++++++ crates/ide/src/diagnostics.rs | 21 +++++++++++- .../linter/elp_lint_warnings_as_errors.toml | 2 ++ .../configure-project/elp-lint-toml.md | 23 ++++++++++--- 7 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 crates/elp/src/resources/test/linter/warnings_as_errors.stdout create mode 100644 test_projects/linter/elp_lint_warnings_as_errors.toml diff --git a/crates/elp/src/bin/lint_cli.rs b/crates/elp/src/bin/lint_cli.rs index 8bfd352a74..9c7ab42e05 100644 --- a/crates/elp/src/bin/lint_cli.rs +++ b/crates/elp/src/bin/lint_cli.rs @@ -935,6 +935,7 @@ mod tests { use elp::cli::Fake; use elp_ide::FunctionMatch; use elp_ide::diagnostics::DiagnosticCode; + use elp_ide::diagnostics::ErlangServiceConfig; use elp_ide::diagnostics::Lint; use elp_ide::diagnostics::LintsFromConfig; use elp_ide::diagnostics::ReplaceCall; @@ -968,12 +969,18 @@ mod tests { enabled_lints: vec![DiagnosticCode::HeadMismatch], disabled_lints: vec![], linters: FxHashMap::default(), + erlang_service: ErlangServiceConfig { + warnings_as_errors: true, + }, }) .unwrap(); expect![[r#" enabled_lints = ["P1700"] disabled_lints = [] + + [erlang_service] + warnings_as_errors = true [[ad_hoc_lints.lints]] type = "ReplaceCall" @@ -1007,6 +1014,9 @@ mod tests { TrivialMatch, ], disabled_lints: [], + erlang_service: ErlangServiceConfig { + warnings_as_errors: false, + }, ad_hoc_lints: LintsFromConfig { lints: [], }, diff --git a/crates/elp/src/bin/main.rs b/crates/elp/src/bin/main.rs index 142b4f952e..8ecbb520be 100644 --- a/crates/elp/src/bin/main.rs +++ b/crates/elp/src/bin/main.rs @@ -1863,6 +1863,21 @@ mod tests { } } + #[test] + fn lint_warnings_as_errors() { + simple_snapshot_expect_error( + args_vec![ + "lint", + "--config-file", + "../../test_projects/linter/elp_lint_warnings_as_errors.toml" + ], + "linter", + expect_file!("../resources/test/linter/warnings_as_errors.stdout"), + true, + None, + ) + } + #[test_case(false ; "rebar")] #[test_case(true ; "buck")] fn eqwalizer_tests_check(buck: bool) { diff --git a/crates/elp/src/lib.rs b/crates/elp/src/lib.rs index a75db798c2..2ed7fece37 100644 --- a/crates/elp/src/lib.rs +++ b/crates/elp/src/lib.rs @@ -159,6 +159,7 @@ pub fn read_lint_config_file(project: &Path, config_file: &Option) -> Re mod tests { use elp_ide::FunctionMatch; use elp_ide::diagnostics::DiagnosticCode; + use elp_ide::diagnostics::ErlangServiceConfig; use elp_ide::diagnostics::Lint; use elp_ide::diagnostics::LintsFromConfig; use elp_ide::diagnostics::ReplaceCall; @@ -190,10 +191,14 @@ mod tests { ], }, linters: FxHashMap::default(), + erlang_service: ErlangServiceConfig::default(), }; expect![[r#" enabled_lints = ["W0011"] disabled_lints = [] + + [erlang_service] + warnings_as_errors = false [[ad_hoc_lints.lints]] type = "ReplaceCall" diff --git a/crates/elp/src/resources/test/linter/warnings_as_errors.stdout b/crates/elp/src/resources/test/linter/warnings_as_errors.stdout new file mode 100644 index 0000000000..d0bd9289b4 --- /dev/null +++ b/crates/elp/src/resources/test/linter/warnings_as_errors.stdout @@ -0,0 +1,34 @@ +Reporting all diagnostics codes +Diagnostics reported in 7 modules: + app_a: 8 + 4:4-4:34::[Warning] [W0011] module `app_a` belongs to app `app_a`, but reads env for `misc` + 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. + 8:0-8:4::[Error] [P1700] head mismatch 'fooX' vs 'food' + 7:6-7:7::[Warning] [W0018] Unexpected ';' + 0:8-0:13::[WeakWarning] [W0046] The module is not documented. + 12:4-12:13::[Warning] [W0026] Function 'app_a:baz/2' is not exported. + 11:0-11:3::[Error] [L1230] function bar/0 is unused + 15:0-15:3::[Error] [L1230] function baz/2 is unused + app_a_edoc: 2 + 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. + 0:8-0:18::[WeakWarning] [W0046] The module is not documented. + app_a_unused_param: 3 + 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. + 0:8-0:26::[WeakWarning] [W0046] The module is not documented. + 4:4-4:5::[Error] [L1268] variable 'X' is unused + app_b: 3 + 4:4-4:34::[Warning] [W0011] module `app_b` belongs to app `app_b`, but reads env for `misc` + 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. + 0:8-0:13::[WeakWarning] [W0046] The module is not documented. + app_b_unused_param: 3 + 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. + 0:8-0:26::[WeakWarning] [W0046] The module is not documented. + 4:4-4:5::[Error] [L1268] variable 'X' is unused + expression_updates_literal: 3 + 1:8-1:34::[WeakWarning] [W0046] The module is not documented. + 6:0-6:5::[Error] [L1309] missing specification for function a_fun/0 + 7:6-8:15::[Error] [L1318] expression updates a literal + spelling: 3 + 1:1-1:9::[Error] [W0013] misspelled attribute, saw 'dyalizer' but expected 'dialyzer' + 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. + 0:8-0:16::[WeakWarning] [W0046] The module is not documented. diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index c7dfcbab2f..7cc5adb5e9 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -1011,6 +1011,15 @@ impl DiagnosticsConfig { _ => false, }) } + + fn erlang_service_warning_severity(&self) -> Severity { + if let Some(lint_config) = &self.lint_config + && lint_config.erlang_service.warnings_as_errors + { + return Severity::Error; + } + Severity::Warning + } } impl LintConfig { @@ -1066,11 +1075,19 @@ pub struct LintConfig { #[serde(default)] pub disabled_lints: Vec, #[serde(default)] + pub erlang_service: ErlangServiceConfig, + #[serde(default)] pub ad_hoc_lints: LintsFromConfig, #[serde(default)] pub linters: FxHashMap, } +#[derive(Deserialize, Serialize, Default, Debug, Clone)] +pub struct ErlangServiceConfig { + #[serde(default)] + pub warnings_as_errors: bool, +} + #[derive(Deserialize, Serialize, Default, Debug, Clone)] pub struct FunctionCallLinterConfig { exclude: Option>, @@ -1774,6 +1791,8 @@ pub fn erlang_service_diagnostics( warning_info.insert(val); }); + let warning_severity = config.erlang_service_warning_severity(); + let diags: Vec<(FileId, Diagnostic)> = error_info .into_iter() .map(|(file_id, start, end, code, msg)| { @@ -1801,7 +1820,7 @@ pub fn erlang_service_diagnostics( msg, TextRange::new(start, end), ) - .with_severity(Severity::Warning), + .with_severity(warning_severity), ), ) }), diff --git a/test_projects/linter/elp_lint_warnings_as_errors.toml b/test_projects/linter/elp_lint_warnings_as_errors.toml new file mode 100644 index 0000000000..2fe076a5b4 --- /dev/null +++ b/test_projects/linter/elp_lint_warnings_as_errors.toml @@ -0,0 +1,2 @@ +[erlang_service] +warnings_as_errors = true diff --git a/website/docs/get-started/configure-project/elp-lint-toml.md b/website/docs/get-started/configure-project/elp-lint-toml.md index 46d0f3f2c9..0fa618d4a0 100644 --- a/website/docs/get-started/configure-project/elp-lint-toml.md +++ b/website/docs/get-started/configure-project/elp-lint-toml.md @@ -5,7 +5,7 @@ sidebar_position: 6 # The `.elp_lint.toml` Configuration File A `.elp_lint.toml` configuration file can be added to a project's root directory to -customize the list of diagnostics (aka linters) enabled. +customize the list of diagnostics (aka linters) enabled and other behaviours. ## A Sample `.elp_lint.toml` Configuration File @@ -16,11 +16,26 @@ disabled_lints = [ 'W0011', # Accessing different app's application env 'W0014' # Cross node eval ] +[erlang_service] +warnings_as_errors = true ``` -Where you can use: +## enabled_lints -* `enabled_lints`: To enable a diagnostic that would otherwise be disabled by default -* `disabled_lints`: To disable a diagnostic that would otherwise be enabled by default +You can use this option to enable diagnostics that would otherwise be disabled by default Please refer to the [Erlang Error Index](../../erlang-error-index/erlang-error-index.mdx) for a reference of supported diagnostic codes. + +## disabled_lints + +You can use this option to disable a diagnostic that would otherwise be enabled by default + +Please refer to the [Erlang Error Index](../../erlang-error-index/erlang-error-index.mdx) for a reference of supported diagnostic codes. + +## [erlang_service] + +Through the `erlang_service` section, you can configure the behaviour of the Erlang Service (the Erlang sidecar next to ELP). + +### warnings_as_errors + +If set to `true`, warnings producing by the Erlang Service will be treated as errors. From ea45ecfdd198a73353ce99f904c83729b1e8dc04 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Thu, 18 Sep 2025 05:43:32 -0700 Subject: [PATCH 040/314] BE: always check stderr in elp lint fix tests Summary: The apply lint process can error. Make sure we check in our tests that it does not. This makes it easier to catch regressions. Reviewed By: TD5 Differential Revision: D82713866 fbshipit-source-id: dac9e83f392d5e47a46aa4ffb81d8f2ddd5c79f7 --- crates/elp/src/bin/main.rs | 44 ++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/elp/src/bin/main.rs b/crates/elp/src/bin/main.rs index 8ecbb520be..4aa7bfa869 100644 --- a/crates/elp/src/bin/main.rs +++ b/crates/elp/src/bin/main.rs @@ -1330,7 +1330,7 @@ mod tests { fn lint_config_file_used(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--diagnostic-filter", @@ -1347,6 +1347,9 @@ mod tests { Path::new("../resources/test/lint/lint_recursive"), &[], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("bad test"); } @@ -1537,7 +1540,7 @@ mod tests { fn lint_explicit_enable_diagnostic(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--config-file", @@ -1552,6 +1555,9 @@ mod tests { Path::new("../resources/test/lint/lint_recursive"), &[], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("bad test"); } @@ -1561,7 +1567,7 @@ mod tests { fn lint_json_output(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--diagnostic-filter", @@ -1579,6 +1585,9 @@ mod tests { Path::new("../resources/test/lint/lint_recursive"), &[], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("bad test"); } @@ -1588,7 +1597,7 @@ mod tests { fn lint_json_output_prefix(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--diagnostic-filter", @@ -1608,6 +1617,9 @@ mod tests { Path::new("../resources/test/lint/lint_recursive"), &[], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("bad test"); } @@ -1617,7 +1629,7 @@ mod tests { fn lint_applies_fix_using_to_dir(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--module", @@ -1626,7 +1638,7 @@ mod tests { "P1700", "--to", tmp_path, - "--apply-fix" + "--apply-fix", ], "diagnostics", expect_file!("../resources/test/diagnostics/parse_elp_lint_fix.stdout"), @@ -1637,6 +1649,9 @@ mod tests { Path::new("../resources/test/lint/head_mismatch"), &[("app_a/src/lints.erl", "lints.erl")], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("Bad test"); } @@ -1646,7 +1661,7 @@ mod tests { fn lint_applies_fix_using_to_dir_json_output(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--module", @@ -1668,6 +1683,9 @@ mod tests { Path::new("../resources/test/lint/head_mismatch"), &[("app_a/src/lints.erl", "lints.erl")], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("Bad test"); } @@ -1688,7 +1706,7 @@ mod tests { fn do_lint_applies_fix_in_place(buck: bool) { let project = "in_place_tests"; - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--module", @@ -1707,6 +1725,9 @@ mod tests { Path::new("../resources/test/lint/head_mismatch"), &[("app_a/src/lints.erl", "app_a/src/lints.erl")], true, + Some(expect![[r#" + Errors found + "#]]), ) .expect("Bad test"); } @@ -1746,7 +1767,7 @@ mod tests { fn lint_applies_code_action_fixme_if_requested(buck: bool) { let tmp_dir = make_tmp_dir(); let tmp_path = tmp_dir.path(); - check_lint_fix( + check_lint_fix_stderr( args_vec![ "lint", "--module", @@ -1767,6 +1788,9 @@ mod tests { Path::new("../resources/test/lint/ignore_app_env"), &[("app_a/src/spelling.erl", "spelling.erl")], false, + Some(expect![[r#" + Errors found + "#]]), ) .expect("Bad test"); } @@ -2400,6 +2424,8 @@ mod tests { ); if let Some(expected_stderr) = expected_stderr { expected_stderr.assert_eq(&stderr); + } else { + expect![[""]].assert_eq(&stderr); } assert_normalised_file(expected, &stdout, path, false); for (expected_file, file) in files { From b32bd857fddae4fc5b54d7849589fd1f505477ae Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Fri, 19 Sep 2025 03:32:39 -0700 Subject: [PATCH 041/314] Convert cross_node_eval linter to use trait Summary: Convert the `cross_node_eval` linter to use the `FunctionCallLinter` trait. Reviewed By: alanz Differential Revision: D82633601 fbshipit-source-id: 131c5cfb09b5747813ee0e551bf20c4324b4d79b --- crates/elp/src/bin/main.rs | 17 +++ .../linter/custom_function_matches.stdout | 5 + ...e_elp_no_lint_specified_json_output.stdout | 3 + .../parse_elp_no_lint_specified_output.stdout | 6 +- .../test/linter/warnings_as_errors.stdout | 6 +- crates/ide/src/diagnostics.rs | 10 +- crates/ide/src/diagnostics/cross_node_eval.rs | 126 ++++++------------ .../app_a/src/custom_function_matches.erl | 16 +++ .../elp_lint_custom_function_matches.toml | 10 ++ .../w/{W0014.md => W0014.mdx} | 4 + 10 files changed, 113 insertions(+), 90 deletions(-) create mode 100644 crates/elp/src/resources/test/linter/custom_function_matches.stdout create mode 100644 test_projects/linter/app_a/src/custom_function_matches.erl create mode 100644 test_projects/linter/elp_lint_custom_function_matches.toml rename website/docs/erlang-error-index/w/{W0014.md => W0014.mdx} (82%) diff --git a/crates/elp/src/bin/main.rs b/crates/elp/src/bin/main.rs index 4aa7bfa869..d87fb89a8f 100644 --- a/crates/elp/src/bin/main.rs +++ b/crates/elp/src/bin/main.rs @@ -1902,6 +1902,23 @@ mod tests { ) } + #[test] + fn lint_custom_function_matches() { + simple_snapshot( + args_vec![ + "lint", + "--config-file", + "../../test_projects/linter/elp_lint_custom_function_matches.toml", + "--module", + "custom_function_matches" + ], + "linter", + expect_file!("../resources/test/linter/custom_function_matches.stdout"), + true, + None, + ) + } + #[test_case(false ; "rebar")] #[test_case(true ; "buck")] fn eqwalizer_tests_check(buck: bool) { diff --git a/crates/elp/src/resources/test/linter/custom_function_matches.stdout b/crates/elp/src/resources/test/linter/custom_function_matches.stdout new file mode 100644 index 0000000000..08c0321771 --- /dev/null +++ b/crates/elp/src/resources/test/linter/custom_function_matches.stdout @@ -0,0 +1,5 @@ +Reporting all diagnostics codes +module specified: custom_function_matches +Diagnostics reported in 1 modules: + custom_function_matches: 1 + 13:4-13:25::[Warning] [W0017] Function 'not_excluded:function/0' is undefined. diff --git a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout index 5c002e54de..c1475aa449 100644 --- a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout +++ b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_json_output.stdout @@ -17,6 +17,9 @@ {"path":"app_b/src/app_b_unused_param.erl","line":1,"char":1,"code":"ELP","severity":"error","name":"W0012 (compile-warn-missing-spec)","original":null,"replacement":null,"description":"Please add \"-compile(warn_missing_spec_all).\" to the module. If exported functions are not all specced, they need to be specced.\n\nFor more information see: /erlang-error-index/w/W0012"} {"path":"app_b/src/app_b_unused_param.erl","line":1,"char":9,"code":"ELP","severity":"disabled","name":"W0046 (undocumented_module)","original":null,"replacement":null,"description":"The module is not documented.\n\nFor more information see: /erlang-error-index/w/W0046"} {"path":"app_b/src/app_b_unused_param.erl","line":5,"char":5,"code":"ELP","severity":"warning","name":"L1268 (L1268)","original":null,"replacement":null,"description":"variable 'X' is unused\n\nFor more information see: /erlang-error-index/l/L1268"} +{"path":"app_a/src/custom_function_matches.erl","line":13,"char":5,"code":"ELP","severity":"warning","name":"W0017 (undefined_function)","original":null,"replacement":null,"description":"Function 'excluded:function/0' is undefined.\n\nFor more information see: /erlang-error-index/w/W0017"} +{"path":"app_a/src/custom_function_matches.erl","line":14,"char":5,"code":"ELP","severity":"warning","name":"W0017 (undefined_function)","original":null,"replacement":null,"description":"Function 'not_excluded:function/0' is undefined.\n\nFor more information see: /erlang-error-index/w/W0017"} +{"path":"app_a/src/custom_function_matches.erl","line":15,"char":5,"code":"ELP","severity":"warning","name":"W0017 (undefined_function)","original":null,"replacement":null,"description":"Function 'cross:call/0' is undefined.\n\nFor more information see: /erlang-error-index/w/W0017"} {"path":"app_a/src/expression_updates_literal.erl","line":2,"char":9,"code":"ELP","severity":"disabled","name":"W0046 (undocumented_module)","original":null,"replacement":null,"description":"The module is not documented.\n\nFor more information see: /erlang-error-index/w/W0046"} {"path":"app_a/src/expression_updates_literal.erl","line":7,"char":1,"code":"ELP","severity":"warning","name":"L1309 (L1309)","original":null,"replacement":null,"description":"missing specification for function a_fun/0\n\nFor more information see: /erlang-error-index/l/L1309"} {"path":"app_a/src/expression_updates_literal.erl","line":8,"char":7,"code":"ELP","severity":"warning","name":"L1318 (L1318)","original":null,"replacement":null,"description":"expression updates a literal\n\nFor more information see: /erlang-error-index/l/L1318"} diff --git a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout index 5099c34a65..01ac36d828 100644 --- a/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout +++ b/crates/elp/src/resources/test/linter/parse_elp_no_lint_specified_output.stdout @@ -1,5 +1,5 @@ Reporting all diagnostics codes -Diagnostics reported in 7 modules: +Diagnostics reported in 8 modules: app_a: 8 4:4-4:34::[Warning] [W0011] module `app_a` belongs to app `app_a`, but reads env for `misc` 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. @@ -24,6 +24,10 @@ Diagnostics reported in 7 modules: 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. 0:8-0:26::[WeakWarning] [W0046] The module is not documented. 4:4-4:5::[Warning] [L1268] variable 'X' is unused + custom_function_matches: 3 + 12:4-12:21::[Warning] [W0017] Function 'excluded:function/0' is undefined. + 13:4-13:25::[Warning] [W0017] Function 'not_excluded:function/0' is undefined. + 14:4-14:14::[Warning] [W0017] Function 'cross:call/0' is undefined. expression_updates_literal: 3 1:8-1:34::[WeakWarning] [W0046] The module is not documented. 6:0-6:5::[Warning] [L1309] missing specification for function a_fun/0 diff --git a/crates/elp/src/resources/test/linter/warnings_as_errors.stdout b/crates/elp/src/resources/test/linter/warnings_as_errors.stdout index d0bd9289b4..8ffe5b1201 100644 --- a/crates/elp/src/resources/test/linter/warnings_as_errors.stdout +++ b/crates/elp/src/resources/test/linter/warnings_as_errors.stdout @@ -1,5 +1,5 @@ Reporting all diagnostics codes -Diagnostics reported in 7 modules: +Diagnostics reported in 8 modules: app_a: 8 4:4-4:34::[Warning] [W0011] module `app_a` belongs to app `app_a`, but reads env for `misc` 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. @@ -24,6 +24,10 @@ Diagnostics reported in 7 modules: 0:0-0:0::[Error] [W0012] Please add "-compile(warn_missing_spec_all)." to the module. If exported functions are not all specced, they need to be specced. 0:8-0:26::[WeakWarning] [W0046] The module is not documented. 4:4-4:5::[Error] [L1268] variable 'X' is unused + custom_function_matches: 3 + 12:4-12:21::[Warning] [W0017] Function 'excluded:function/0' is undefined. + 13:4-13:25::[Warning] [W0017] Function 'not_excluded:function/0' is undefined. + 14:4-14:14::[Warning] [W0017] Function 'cross:call/0' is undefined. expression_updates_literal: 3 1:8-1:34::[WeakWarning] [W0046] The module is not documented. 6:0-6:5::[Error] [L1309] missing specification for function a_fun/0 diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 7cc5adb5e9..2f80f5cf89 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -645,9 +645,14 @@ impl FunctionCallDiagnostics for T { ) -> Vec { let mut diagnostics = Vec::new(); let matches = self.matches_functions(); + let included_matches_from_config = &config.include.clone().unwrap_or_default(); let excluded_matches = self.excludes_functions(); let excluded_matches_from_config = &config.exclude.clone().unwrap_or_default(); - let mfas: Vec<(&FunctionMatch, ())> = matches.iter().map(|m| (m, ())).collect(); + let mfas: Vec<(&FunctionMatch, ())> = matches + .iter() + .chain(included_matches_from_config) + .map(|m| (m, ())) + .collect(); let excluded_mfas: Vec<(&FunctionMatch, ())> = excluded_matches .iter() .chain(excluded_matches_from_config) @@ -1090,6 +1095,7 @@ pub struct ErlangServiceConfig { #[derive(Deserialize, Serialize, Default, Debug, Clone)] pub struct FunctionCallLinterConfig { + include: Option>, exclude: Option>, } @@ -1351,7 +1357,6 @@ pub fn diagnostics_descriptors<'a>() -> Vec<&'a DiagnosticDescriptor<'a>> { &deprecated_function::DESCRIPTOR, &head_mismatch::DESCRIPTOR_SEMANTIC, &missing_separator::DESCRIPTOR, - &cross_node_eval::DESCRIPTOR, &boolean_precedence::DESCRIPTOR, &record_tuple_match::DESCRIPTOR, &unspecific_include::DESCRIPTOR, @@ -1423,6 +1428,7 @@ const FUNCTION_CALL_LINTERS: &[&dyn FunctionCallDiagnostics] = &[ &atoms_exhaustion::LINTER, &undefined_function::LINTER, &unexported_function::LINTER, + &cross_node_eval::LINTER, ]; /// SSR pattern linters that use structural search and replace patterns diff --git a/crates/ide/src/diagnostics/cross_node_eval.rs b/crates/ide/src/diagnostics/cross_node_eval.rs index 36ae2cb383..a61c058a40 100644 --- a/crates/ide/src/diagnostics/cross_node_eval.rs +++ b/crates/ide/src/diagnostics/cross_node_eval.rs @@ -12,102 +12,56 @@ //! //! Return a diagnostic for rpc calls to remote nodes. -use elp_ide_db::elp_base_db::FileId; -use hir::FunctionDef; -use hir::Semantic; -use lazy_static::lazy_static; - -use super::Diagnostic; -use super::DiagnosticConditions; -use super::DiagnosticDescriptor; use crate::codemod_helpers::FunctionMatch; -use crate::codemod_helpers::MatchCtx; -use crate::codemod_helpers::find_call_in_function; -// @fb-only use crate::diagnostics::DiagnosticCode; +use crate::diagnostics::FunctionCallLinter; +use crate::diagnostics::Linter; use crate::diagnostics::Severity; +use crate::lazy_function_matches; -pub(crate) static DESCRIPTOR: DiagnosticDescriptor = DiagnosticDescriptor { - conditions: DiagnosticConditions { - experimental: false, - include_generated: false, - include_tests: false, - default_disabled: false, - }, - checker: &|diags, sema, file_id, _ext| { - cross_node_eval(diags, sema, file_id); - }, -}; +pub(crate) struct CrossNodeEvalLinter; -fn cross_node_eval(diags: &mut Vec, sema: &Semantic, file_id: FileId) { - sema.def_map(file_id) - .get_functions() - .for_each(|(_, def)| check_function(diags, sema, def)); -} - -fn check_function(diags: &mut Vec, sema: &Semantic, def: &FunctionDef) { - lazy_static! { - static ref BAD_MATCHES: Vec<(FunctionMatch, Option<&'static str>)> = vec![ - vec![(FunctionMatch::m("rpc"), None)], - vec![(FunctionMatch::mf( - "erts_internal_dist", - "dist_spawn_request", - ), None)], - vec![(FunctionMatch::mf("sys", "install"), None)], - FunctionMatch::mfas("erlang", "spawn", vec![2, 4]).into_iter().map(|fm| (fm,None)).collect(), - FunctionMatch::mfas("erlang", "spawn_link", vec![2, 4]).into_iter().map(|fm| (fm,None)).collect(), - FunctionMatch::mfas("erlang", "spawn_monitor", vec![2, 4]).into_iter().map(|fm| (fm,None)).collect(), - FunctionMatch::mfas("erlang", "spawn_opt", vec![3, 5]).into_iter().map(|fm| (fm,None)).collect(), - FunctionMatch::mfas("sys", "install", vec![2, 3]).into_iter().map(|fm| (fm,None)).collect(), - // @fb-only - ] - .into_iter() - .flatten() - .collect::>(); - - static ref BAD_MATCHES_MFAS: Vec<(&'static FunctionMatch, &'static Option<&'static str>)> - = BAD_MATCHES.iter().map(|(b, m)| (b, m)).collect::>(); +impl Linter for CrossNodeEvalLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::CrossNodeEval + } + fn description(&self) -> &'static str { + "Production code must not use cross node eval (e.g. `rpc:call()`)" + } + fn severity(&self) -> Severity { + Severity::Error + } + fn should_process_test_files(&self) -> bool { + false } - - process_badmatches(diags, sema, def, &BAD_MATCHES_MFAS); } -fn process_badmatches( - diags: &mut Vec, - sema: &Semantic, - def: &FunctionDef, - bad: &[(&FunctionMatch, &Option<&str>)], -) { - find_call_in_function( - diags, - sema, - def, - bad, - &[], - &move |ctx| match ctx.t { - Some(m) => Some(*m), - None => Some(r#"Production code must not use cross node eval (e.g. `rpc:call()`)"#), - }, - &move |ctx @ MatchCtx { - sema, - def_fb, - extra, - .. - }: MatchCtx<'_, &str>| { - let range = ctx.range_mf_or_macro(); - if def.file.file_id == range.file_id { - let diag = Diagnostic::new(DiagnosticCode::CrossNodeEval, *extra, range.range) - .with_severity(Severity::Error) - .with_ignore_fix(sema, def_fb.file_id()); - Some(diag) - } else { - None - } - }, - ); +impl FunctionCallLinter for CrossNodeEvalLinter { + type Context = (); + + fn matches_functions(&self) -> Vec { + lazy_function_matches![ + vec![ + vec![FunctionMatch::m("rpc")], + vec![FunctionMatch::mf( + "erts_internal_dist", + "dist_spawn_request" + )], + vec![FunctionMatch::mf("sys", "install")], + FunctionMatch::mfas("erlang", "spawn", vec![2, 4]), + FunctionMatch::mfas("erlang", "spawn_link", vec![2, 4]), + FunctionMatch::mfas("erlang", "spawn_monitor", vec![2, 4]), + FunctionMatch::mfas("erlang", "spawn_opt", vec![3, 5]), + FunctionMatch::mfas("sys", "install", vec![2, 3]), + ] + .into_iter() + .flatten() + .collect::>() + ] + } } -// --------------------------------------------------------------------- +pub static LINTER: CrossNodeEvalLinter = CrossNodeEvalLinter; #[cfg(test)] mod tests { diff --git a/test_projects/linter/app_a/src/custom_function_matches.erl b/test_projects/linter/app_a/src/custom_function_matches.erl new file mode 100644 index 0000000000..e583be0ce5 --- /dev/null +++ b/test_projects/linter/app_a/src/custom_function_matches.erl @@ -0,0 +1,16 @@ +-module(custom_function_matches). +-moduledoc """ +Tests for custom function matches. +""". +-compile(warn_missing_spec_all). +-export([main/0]). + +-spec main() -> ok. +-doc """ +Main function. +""". +main() -> + excluded:function(), + not_excluded:function(), + cross:call(), + ok. diff --git a/test_projects/linter/elp_lint_custom_function_matches.toml b/test_projects/linter/elp_lint_custom_function_matches.toml new file mode 100644 index 0000000000..dbdc482db0 --- /dev/null +++ b/test_projects/linter/elp_lint_custom_function_matches.toml @@ -0,0 +1,10 @@ +[linters.undefined_function] +exclude = [ + {type = "MFA", mfa = "excluded:function/0"}, + {type = "M", module = "cross"} +] + +[linters.cross_node_eval] +include = [ + {type = "M", module = "cross_node"} +] diff --git a/website/docs/erlang-error-index/w/W0014.md b/website/docs/erlang-error-index/w/W0014.mdx similarity index 82% rename from website/docs/erlang-error-index/w/W0014.md rename to website/docs/erlang-error-index/w/W0014.mdx index 75811d847d..ea1d455370 100644 --- a/website/docs/erlang-error-index/w/W0014.md +++ b/website/docs/erlang-error-index/w/W0014.mdx @@ -2,6 +2,8 @@ sidebar_position: 14 --- +import MetaOnlyW0014 from '../../../fb/components/_META_ONLY_W0014.md'; + # W0014 - Cross Node Evaluation Not Allowed ## Error @@ -17,3 +19,5 @@ sidebar_position: 14 The error is indicating that remote execution is happening between two nodes, in an environment where this is not allowed. To fix the error either remove the invocation or ignore the problem via [the standard `elp:ignore` mechanism](../erlang-error-index.mdx#ignoring-diagnostics). + + From f7e9bf9c4e3081bb86aaccd449788956bd7f3293 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Fri, 19 Sep 2025 03:32:39 -0700 Subject: [PATCH 042/314] Use local def_map in unused_macro linter Summary: By using the local def_map, we avoid external file ids altogether. Reviewed By: alanz Differential Revision: D82636531 fbshipit-source-id: d0015a6002b14c237b2be5722deffa194543a5a1 --- crates/ide/src/diagnostics/unused_macro.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/ide/src/diagnostics/unused_macro.rs b/crates/ide/src/diagnostics/unused_macro.rs index 516a9176a9..60bb43e077 100644 --- a/crates/ide/src/diagnostics/unused_macro.rs +++ b/crates/ide/src/diagnostics/unused_macro.rs @@ -48,14 +48,11 @@ fn unused_macro( file_kind: FileKind, ) -> Option<()> { if file_kind.is_module() { - let def_map = sema.def_map(file_id); + let def_map = sema.def_map_local(file_id); for (name, def) in def_map.get_macros() { - // Only run the check for macros defined in the local module, - // not in the included files. - if def.file.file_id == file_id - && !SymbolDefinition::Define(def.clone()) - .usages(sema) - .at_least_one() + if !SymbolDefinition::Define(def.clone()) + .usages(sema) + .at_least_one() { let source = def.source(sema.db.upcast()); let macro_range = extend_range(source.syntax()); From 7f67904387a08b5458d1074d38d2f65665b2ab09 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Mon, 22 Sep 2025 06:24:09 -0700 Subject: [PATCH 043/314] BE: Use a vec for load telemetry Summary: We currently send a summary telemetry message when a progress bar ends, with each of the steps included in it. This is currently stored in rust as a `FxHashMap` of segment title and time taken. This does not keep the relative ordering of the segments, so detailed knowledge of the originating code is needed to understand the order. Simplify this by using a `Vec<(name,time)>` instead. Reviewed By: robertoaloi Differential Revision: D82953458 fbshipit-source-id: 8f37e3135d5781505c60c3f30556273859eec7f9 --- crates/elp/src/server/telemetry_manager.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/elp/src/server/telemetry_manager.rs b/crates/elp/src/server/telemetry_manager.rs index e8a54309a4..8cb1f2f104 100644 --- a/crates/elp/src/server/telemetry_manager.rs +++ b/crates/elp/src/server/telemetry_manager.rs @@ -115,7 +115,7 @@ pub(crate) struct ReporterTelemetry { segment_start_time: SystemTime, #[serde(skip_serializing)] segment_message: String, - segments: FxHashMap, + segments: Vec<(String, u32)>, } impl ReporterTelemetryManager { @@ -130,7 +130,7 @@ impl ReporterTelemetryManager { title: title.clone(), segment_start_time: time, segment_message: title, - segments: FxHashMap::default(), + segments: Vec::new(), }; self.active.insert(token, val.clone()); val @@ -151,10 +151,10 @@ impl ReporterTelemetryManager { impl ReporterTelemetry { fn update(&mut self, message: Option) { // First capture the prior segment timing - self.segments.insert( + self.segments.push(( self.segment_message.clone(), self.segment_duration().as_millis() as u32, - ); + )); // Then do the update if let Some(message) = message { let time = SystemTime::now(); @@ -231,10 +231,16 @@ mod test { // We sometimes run on a rollover boundary let expected = expected.replace("1", "0"); expect![[r#" - { - "Start message": 0, - "update message": 0, - } + [ + ( + "Start message", + 0, + ), + ( + "update message", + 0, + ), + ] "#]] .assert_eq(&expected); } From 91b432fae9677bcd597ed383fe77867e1548ecad Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Mon, 22 Sep 2025 06:58:20 -0700 Subject: [PATCH 044/314] BE: Provide explicit state change telemetry Summary: There is chronicle logging of the state change notification we send, but this is marked against the time-stamp when it is received in SCUBA, and this is often inaccurate. Provide an explicit telemetry item for it, including the current time as seen by ELP Reviewed By: robertoaloi Differential Revision: D82954038 fbshipit-source-id: e048b8ace05d5169f7333f17961a0b9112cbf7d4 --- crates/elp/src/server.rs | 31 +++++++++++++++++++++++++++++++ crates/elp_log/src/telemetry.rs | 14 +++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/elp/src/server.rs b/crates/elp/src/server.rs index 634854bf24..2c0ebbeb3b 100644 --- a/crates/elp/src/server.rs +++ b/crates/elp/src/server.rs @@ -90,6 +90,7 @@ use lsp_types::request::Request as _; use parking_lot::Mutex; use parking_lot::RwLock; use parking_lot::RwLockWriteGuard; +use serde::Serialize; use telemetry_manager::TelemetryManager; use vfs::Change; use vfs::loader::LoadingProgress; @@ -201,6 +202,17 @@ pub enum Status { ShuttingDown, } +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Status::Initialising => write!(f, "Initialising"), + Status::Loading(_progress_bar) => write!(f, "Loading"), + Status::Running => write!(f, "Running"), + Status::ShuttingDown => write!(f, "ShuttingDown"), + } + } +} + /// For buck projects we cannot rely on source roots, so if a new file /// is added we need to query the project model for where it fits in. /// But we must distinguish between the initial load, where every file @@ -1495,6 +1507,7 @@ impl Server { fn transition(&mut self, status: Status) { if self.status != status { log::info!("transitioning from {:?} to {:?}", self.status, status); + self.send_state_change_telemetry(&status); self.status = status; if self.config.server_status_notification() { self.send_notification::(lsp_ext::StatusParams { @@ -1504,6 +1517,24 @@ impl Server { } } + fn send_state_change_telemetry(&mut self, status: &Status) { + #[derive(Serialize)] + struct ServertState { + title: String, + old_state: String, + new_state: String, + } + let data = ServertState { + title: "ELP changing state".to_string(), + old_state: self.status.to_string(), + new_state: status.to_string(), + }; + let data = serde_json::to_value(data).unwrap_or_else(|err| { + serde_json::Value::String(format!("JSON serialization failed: {err}")) + }); + telemetry::send("elp_reporter_telemetry".to_string(), data); + } + fn show_message(&mut self, params: ShowMessageParams) { self.send_notification::(params) } diff --git a/crates/elp_log/src/telemetry.rs b/crates/elp_log/src/telemetry.rs index 68354b564a..cbfa40d1fd 100644 --- a/crates/elp_log/src/telemetry.rs +++ b/crates/elp_log/src/telemetry.rs @@ -79,7 +79,7 @@ fn do_send( } pub fn send(typ: String, data: serde_json::Value) { - do_send(typ, data, None, None); + do_send(typ, data, None, Some(SystemTime::now())); } pub fn send_with_duration( @@ -93,6 +93,8 @@ pub fn send_with_duration( #[cfg(test)] mod tests { + use std::time::SystemTime; + use expect_test::expect; #[test] @@ -101,12 +103,18 @@ mod tests { let data = serde_json::to_value("Hello telemetry!").unwrap(); super::send(typ, data); - let msg = super::receiver().try_recv().unwrap(); + let mut msg = super::receiver().try_recv().unwrap(); + msg.start_time = msg.start_time.map(|_st| SystemTime::UNIX_EPOCH); expect![[r#" TelemetryMessage { typ: "telemetry", duration_ms: None, - start_time: None, + start_time: Some( + SystemTime { + tv_sec: 0, + tv_nsec: 0, + }, + ), data: String("Hello telemetry!"), } "#]] From 63a28043af45e8523732a5fa824959c003671251 Mon Sep 17 00:00:00 2001 From: Alan Zimmerman Date: Mon, 22 Sep 2025 06:58:20 -0700 Subject: [PATCH 045/314] BE: Include a formatted start and end time stamp in telemetry Summary: The ELP telemetry currently includes a start time, as a struct having seconds since the Unix epoch and nanoseconds. This gives us an accurate start time, as we cannot rely on the SCUBA logged time, as it is the time the message is received, which is after many unpredictable events through the middleware. It also includes a duration in milliseconds. It is hard to work with these in SCUBA, especially for casual comparisons. This diff introduces a string representation of the start time as a time stamp to millisecond resolution, as well as an end time stamp, computed from the start time and duration. Reviewed By: robertoaloi Differential Revision: D82956324 fbshipit-source-id: 8201875d320cca23358bd1c6d780d4c536e1c557 --- crates/elp_log/src/telemetry.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/elp_log/src/telemetry.rs b/crates/elp_log/src/telemetry.rs index cbfa40d1fd..4132de6102 100644 --- a/crates/elp_log/src/telemetry.rs +++ b/crates/elp_log/src/telemetry.rs @@ -20,6 +20,7 @@ use std::time::SystemTime; +use humantime::format_rfc3339_millis; use lazy_static::lazy_static; use serde::Deserialize; use serde::Serialize; @@ -32,6 +33,8 @@ pub struct TelemetryMessage { pub typ: String, pub duration_ms: Option, pub start_time: Option, + pub start_time_string: Option, + pub end_time_string: Option, pub data: TelemetryData, } @@ -56,6 +59,14 @@ fn build_message( duration_ms: Option, start_time: Option, ) -> TelemetryMessage { + let start_time_string = start_time.map(|time| format_rfc3339_millis(time).to_string()); + let end_time_string = start_time.map(|time| match duration_ms { + Some(d) => { + format_rfc3339_millis(time + std::time::Duration::from_millis(d as u64)).to_string() + } + None => format_rfc3339_millis(time).to_string(), + }); + TelemetryMessage { // Note: the "type" field is required, otherwise the telemetry // mapper in the vscode extension will not route the message @@ -64,6 +75,8 @@ fn build_message( typ, duration_ms, start_time, + start_time_string, + end_time_string, data, } } @@ -105,6 +118,12 @@ mod tests { let mut msg = super::receiver().try_recv().unwrap(); msg.start_time = msg.start_time.map(|_st| SystemTime::UNIX_EPOCH); + msg.start_time_string = msg + .start_time_string + .map(|_| "2025-09-22T11:38:41.274Z".to_string()); + msg.end_time_string = msg + .end_time_string + .map(|_| "2025-09-22T11:38:41.321".to_string()); expect![[r#" TelemetryMessage { typ: "telemetry", @@ -115,6 +134,12 @@ mod tests { tv_nsec: 0, }, ), + start_time_string: Some( + "2025-09-22T11:38:41.274Z", + ), + end_time_string: Some( + "2025-09-22T11:38:41.321", + ), data: String("Hello telemetry!"), } "#]] From 03c28b446ceb3bd972fd362b932436e6e011b4f2 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 22 Sep 2025 10:06:04 -0700 Subject: [PATCH 046/314] Convert unused_macro linter to use new GenericLinter trait Summary: Define a new `GenericLinter` trait for linters which do not use either the `find_call_in_function` helper or SSR patterns. As an example, convert the `unused_macro` linter to the new trait. Apart from simplifying the way linters are written, the big advantage is that linters using the trait can leverage common infrastructure (e.g. including an ignore fix) and they can now be configured / enabled via config. Note: the `None` variant for the `DiagnosticTag` was eliminated in favour of the standard `Option` type, which allowed some code simplifications. Reviewed By: alanz Differential Revision: D82638562 fbshipit-source-id: 25c63591f4ae60ea4e89cbbd79e91e9b95d404ed --- crates/elp/src/convert.rs | 9 +- crates/ide/src/diagnostics.rs | 131 ++++++++++++++++++--- crates/ide/src/diagnostics/unused_macro.rs | 103 +++++++++------- 3 files changed, 179 insertions(+), 64 deletions(-) diff --git a/crates/elp/src/convert.rs b/crates/elp/src/convert.rs index 1f42e6f454..4c8d316509 100644 --- a/crates/elp/src/convert.rs +++ b/crates/elp/src/convert.rs @@ -91,16 +91,15 @@ pub fn ide_to_lsp_diagnostic( source, message: d.message.clone(), related_information: from_related(line_index, url, &d.related_info), - tags: lsp_diagnostic_tags(&d.tag), + tags: d.tag.as_ref().map(lsp_diagnostic_tags), data: None, } } -fn lsp_diagnostic_tags(d: &DiagnosticTag) -> Option> { +fn lsp_diagnostic_tags(d: &DiagnosticTag) -> Vec { match d { - DiagnosticTag::None => None, - DiagnosticTag::Unused => Some(vec![lsp_types::DiagnosticTag::UNNECESSARY]), - DiagnosticTag::Deprecated => Some(vec![lsp_types::DiagnosticTag::DEPRECATED]), + DiagnosticTag::Unused => vec![lsp_types::DiagnosticTag::UNNECESSARY], + DiagnosticTag::Deprecated => vec![lsp_types::DiagnosticTag::DEPRECATED], } } diff --git a/crates/ide/src/diagnostics.rs b/crates/ide/src/diagnostics.rs index 2f80f5cf89..5285c188d7 100644 --- a/crates/ide/src/diagnostics.rs +++ b/crates/ide/src/diagnostics.rs @@ -178,24 +178,17 @@ pub const DIAGNOSTIC_WHOLE_FILE_RANGE: TextRange = TextRange::empty(TextSize::ne /// strikethough for Deprecated #[derive(Debug, Clone, PartialEq, Eq)] pub enum DiagnosticTag { - None, Unused, Deprecated, } -impl Default for DiagnosticTag { - fn default() -> Self { - Self::None - } -} - #[derive(Debug, Clone, Default)] pub struct Diagnostic { pub message: String, pub range: TextRange, pub severity: Severity, pub cli_severity: Option, - pub tag: DiagnosticTag, + pub tag: Option, pub categories: FxHashSet, pub fixes: Option>, pub related_info: Option>, @@ -212,7 +205,7 @@ impl Diagnostic { range, severity: Severity::Error, cli_severity: None, - tag: DiagnosticTag::None, + tag: None, categories: FxHashSet::default(), fixes: None, related_info: None, @@ -253,13 +246,18 @@ impl Diagnostic { self } + pub(crate) fn with_tag(mut self, tag: Option) -> Diagnostic { + self.tag = tag; + self + } + pub(crate) fn unused(mut self) -> Diagnostic { - self.tag = DiagnosticTag::Unused; + self.tag = Some(DiagnosticTag::Unused); self } pub(crate) fn deprecated(mut self) -> Diagnostic { - self.tag = DiagnosticTag::Deprecated; + self.tag = Some(DiagnosticTag::Deprecated); self } @@ -798,6 +796,89 @@ impl SsrPatternsDiagnostics for T { } } +pub(crate) struct GenericLinterMatchContext { + range: TextRange, + context: Context, +} + +// A trait that simplifies writing generic linters +pub(crate) trait GenericLinter: Linter { + /// Associated type - each linter defines its own + type Context: Clone + fmt::Debug + PartialEq + Default; + + /// Return a list of matches for the linter + fn matches( + &self, + _sema: &Semantic, + _file_id: FileId, + ) -> Option>> { + None + } + + /// Customize the description based on each match. + /// If implemented, it overrides the value of the `description()`. + fn match_description(&self, _context: &Self::Context) -> Cow<'_, str> { + Cow::Borrowed(self.description()) + } + + fn tag(&self, _context: &Self::Context) -> Option { + None + } + + /// Return an optional vector of quick-fixes + fn fixes( + &self, + _context: &Self::Context, + _sema: &Semantic, + _file_id: FileId, + ) -> Option> { + None + } +} + +// Instances of the GenericLinter trait can specify a custom `Context` type, +// which is passed around in callbacks. +// To be able to keep a registry of all linters we define a blanket implementation for all the methods using the `Context`, +// to keep the code generic while allowing individual linters to specify their own context type. +pub(crate) trait GenericDiagnostics: Linter { + fn diagnostics( + &self, + sema: &Semantic, + file_id: FileId, + severity: Severity, + cli_severity: Severity, + ) -> Vec; +} + +impl GenericDiagnostics for T { + fn diagnostics( + &self, + sema: &Semantic, + file_id: FileId, + severity: Severity, + cli_severity: Severity, + ) -> Vec { + let mut res = Vec::new(); + if let Some(matches) = self.matches(sema, file_id) { + for matched in matches { + let message = self.match_description(&matched.context); + let fixes = self.fixes(&matched.context, sema, file_id); + let tag = self.tag(&matched.context); + let mut d = Diagnostic::new(self.id(), message, matched.range) + .with_fixes(fixes) + .with_tag(tag) + .with_severity(severity) + .with_cli_severity(cli_severity); + if self.can_be_suppressed() { + d = d.with_ignore_fix(sema, file_id); + } + res.push(d); + } + } + res + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiagnosticConditions { pub experimental: bool, @@ -1235,7 +1316,7 @@ pub fn eqwalizer_to_diagnostic( range, severity, cli_severity: None, - tag: DiagnosticTag::None, + tag: None, code: DiagnosticCode::Eqwalizer(d.code.clone()), message, categories: FxHashSet::default(), @@ -1337,7 +1418,6 @@ pub fn diagnostics_descriptors<'a>() -> Vec<&'a DiagnosticDescriptor<'a>> { &unused_function_args::DESCRIPTOR, &trivial_match::DESCRIPTOR, &redundant_assignment::DESCRIPTOR, - &unused_macro::DESCRIPTOR, &unused_record_field::DESCRIPTOR, &mutable_variable::DESCRIPTOR, &effect_free_statement::DESCRIPTOR, @@ -1407,6 +1487,7 @@ pub fn diagnostics_from_descriptors( pub(crate) enum DiagnosticLinter { FunctionCall(&'static dyn FunctionCallDiagnostics), SsrPatterns(&'static dyn SsrPatternsDiagnostics), + Generic(&'static dyn GenericDiagnostics), } impl DiagnosticLinter { @@ -1414,6 +1495,7 @@ impl DiagnosticLinter { match self { DiagnosticLinter::FunctionCall(linter) => *linter, DiagnosticLinter::SsrPatterns(linter) => *linter, + DiagnosticLinter::Generic(linter) => *linter, } } } @@ -1439,6 +1521,9 @@ const SSR_PATTERN_LINTERS: &[&dyn SsrPatternsDiagnostics] = &[ &could_be_a_string_literal::LINTER, ]; +/// Generic linters +const GENERIC_LINTERS: &[&dyn GenericDiagnostics] = &[&unused_macro::LINTER]; + /// Unified registry for all types of linters pub(crate) fn linters() -> Vec { let mut all_linters = Vec::new(); @@ -1457,6 +1542,13 @@ pub(crate) fn linters() -> Vec { .map(|linter| DiagnosticLinter::SsrPatterns(*linter)), ); + // Add generic linters + all_linters.extend( + GENERIC_LINTERS + .iter() + .map(|linter| DiagnosticLinter::Generic(*linter)), + ); + // Add meta-only linters // @fb-only @@ -1530,6 +1622,11 @@ fn diagnostics_from_linters( ); res.extend(diagnostics); } + DiagnosticLinter::Generic(generic_linter) => { + let diagnostics = + generic_linter.diagnostics(sema, file_id, severity, cli_severity); + res.extend(diagnostics); + } } } } @@ -2905,7 +3002,7 @@ baz(1)->4. range: TextRange::new(21.into(), 43.into()), severity: Severity::Error, cli_severity: None, - tag: DiagnosticTag::None, + tag: None, categories: FxHashSet::default(), fixes: None, related_info: None, @@ -2917,7 +3014,7 @@ baz(1)->4. range: TextRange::new(74.into(), 79.into()), severity: Severity::Error, cli_severity: None, - tag: DiagnosticTag::None, + tag: None, categories: FxHashSet::default(), fixes: None, related_info: None, @@ -2929,7 +3026,7 @@ baz(1)->4. range: TextRange::new(82.into(), 99.into()), severity: Severity::Error, cli_severity: None, - tag: DiagnosticTag::None, + tag: None, categories: FxHashSet::default(), fixes: None, related_info: None, @@ -2945,7 +3042,7 @@ baz(1)->4. range: TextRange::new(106.into(), 108.into()), severity: Severity::Error, cli_severity: None, - tag: DiagnosticTag::None, + tag: None, categories: FxHashSet::default(), fixes: None, related_info: None, diff --git a/crates/ide/src/diagnostics/unused_macro.rs b/crates/ide/src/diagnostics/unused_macro.rs index 60bb43e077..99af05370d 100644 --- a/crates/ide/src/diagnostics/unused_macro.rs +++ b/crates/ide/src/diagnostics/unused_macro.rs @@ -12,73 +12,92 @@ // // Return a warning if a macro defined in an .erl file has no references to it +use std::borrow::Cow; + use elp_ide_assists::Assist; use elp_ide_assists::helpers::extend_range; use elp_ide_db::SymbolDefinition; use elp_ide_db::elp_base_db::FileId; -use elp_ide_db::elp_base_db::FileKind; use elp_ide_db::source_change::SourceChange; use elp_syntax::AstNode; use elp_syntax::TextRange; use elp_text_edit::TextEdit; use hir::Semantic; -use super::DiagnosticConditions; -use super::DiagnosticDescriptor; -use crate::Diagnostic; use crate::diagnostics::DiagnosticCode; +use crate::diagnostics::DiagnosticTag; +use crate::diagnostics::GenericLinter; +use crate::diagnostics::GenericLinterMatchContext; +use crate::diagnostics::Linter; use crate::fix; -pub(crate) static DESCRIPTOR: DiagnosticDescriptor = DiagnosticDescriptor { - conditions: DiagnosticConditions { - experimental: false, - include_generated: true, - include_tests: true, - default_disabled: false, - }, - checker: &|diags, sema, file_id, file_kind| { - unused_macro(diags, sema, file_id, file_kind); - }, -}; +pub(crate) struct UnusedMacroLinter; -fn unused_macro( - acc: &mut Vec, - sema: &Semantic, - file_id: FileId, - file_kind: FileKind, -) -> Option<()> { - if file_kind.is_module() { +impl Linter for UnusedMacroLinter { + fn id(&self) -> DiagnosticCode { + DiagnosticCode::UnusedMacro + } + fn description(&self) -> &'static str { + "Unused macro." + } + fn should_process_generated_files(&self) -> bool { + true + } + fn should_process_file_id(&self, sema: &Semantic, file_id: FileId) -> bool { + sema.db.file_kind(file_id).is_module() + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Context { + delete_range: TextRange, + name: String, +} + +impl GenericLinter for UnusedMacroLinter { + type Context = Context; + + fn matches( + &self, + sema: &Semantic, + file_id: FileId, + ) -> Option>> { + let mut res = Vec::new(); let def_map = sema.def_map_local(file_id); for (name, def) in def_map.get_macros() { if !SymbolDefinition::Define(def.clone()) .usages(sema) .at_least_one() + && let Some(range) = def.name_range(sema.db) { - let source = def.source(sema.db.upcast()); - let macro_range = extend_range(source.syntax()); - let name_range = source.name()?.syntax().text_range(); - let d = make_diagnostic(file_id, macro_range, name_range, &name.to_string()); - acc.push(d); + let context = Context { + delete_range: extend_range(def.source(sema.db).syntax()), + name: name.to_string(), + }; + res.push(GenericLinterMatchContext { range, context }); } } + Some(res) + } + + fn match_description(&self, context: &Self::Context) -> Cow<'_, str> { + Cow::Owned(format!("Unused macro ({})", context.name)) + } + + fn tag(&self, _context: &Self::Context) -> Option { + Some(DiagnosticTag::Unused) + } + + fn fixes(&self, context: &Context, _sema: &Semantic, file_id: FileId) -> Option> { + Some(vec![delete_unused_macro( + file_id, + context.delete_range, + &context.name, + )]) } - Some(()) } -fn make_diagnostic( - file_id: FileId, - macro_range: TextRange, - name_range: TextRange, - name: &str, -) -> Diagnostic { - Diagnostic::warning( - DiagnosticCode::UnusedMacro, - name_range, - format!("Unused macro ({name})"), - ) - .unused() - .with_fixes(Some(vec![delete_unused_macro(file_id, macro_range, name)])) -} +pub static LINTER: UnusedMacroLinter = UnusedMacroLinter; fn delete_unused_macro(file_id: FileId, range: TextRange, name: &str) -> Assist { let mut builder = TextEdit::builder(); From e6b0c600336c1baeebf991dbbefeec3a523d02f3 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 22 Sep 2025 10:06:04 -0700 Subject: [PATCH 047/314] Track destination in telemetry for go-to-definition Reviewed By: alanz Differential Revision: D82815837 fbshipit-source-id: c78e5d85ba5660fd6017004e3e7ccc7c9056bf76 --- crates/elp/src/handlers.rs | 7 +++++++ crates/ide_db/src/lib.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/elp/src/handlers.rs b/crates/elp/src/handlers.rs index 8fa9ef7665..cd8f110850 100644 --- a/crates/elp/src/handlers.rs +++ b/crates/elp/src/handlers.rs @@ -32,6 +32,7 @@ use elp_ide::elp_ide_db::elp_base_db::FilePosition; use elp_ide::elp_ide_db::elp_base_db::FileRange; use elp_ide::elp_ide_db::elp_base_db::ProjectId; use elp_log::telemetry; +use elp_syntax::SmolStr; use itertools::Itertools; use lsp_server::ErrorCode; use lsp_types::CallHierarchyIncomingCall; @@ -339,16 +340,22 @@ fn goto_definition_telemetry(snap: &Snapshot, targets: &[NavigationTarget], star .iter() .map(|tgt| snap.file_id_to_url(tgt.file_id)) .collect(); + let target_names: Vec<_> = targets.iter().map(|tgt| tgt.name.clone()).collect(); + let target_kinds: Vec<_> = targets.iter().map(|tgt| tgt.kind).collect(); #[derive(serde::Serialize)] struct Data { targets_include_generated: bool, target_urls: Vec, + target_names: Vec, + target_kinds: Vec, } let detail = Data { targets_include_generated, target_urls, + target_names, + target_kinds, }; let duration = start.elapsed().map(|e| e.as_millis()).unwrap_or(0) as u32; let data = serde_json::to_value(detail).unwrap_or_else(|err| { diff --git a/crates/ide_db/src/lib.rs b/crates/ide_db/src/lib.rs index d9c1f1d8d7..0240d8498d 100644 --- a/crates/ide_db/src/lib.rs +++ b/crates/ide_db/src/lib.rs @@ -335,7 +335,7 @@ impl Includes { // --------------------------------------------------------------------- -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] pub enum SymbolKind { File, Module, From d9f02ee599e7fadd1a97a2bb8137d731536d9eb6 Mon Sep 17 00:00:00 2001 From: Roberto Aloi Date: Mon, 22 Sep 2025 10:06:04 -0700 Subject: [PATCH 048/314] Split token identification and documentation extraction Summary: The API to extract documentation currently takes a *position* as the input and returns an `Option`. This is not ideal, since it collapses two cases into one: `None` can be returned if no meaningful token is identified or if no documentation is available for it. As a first step towards introducing telemetry for hover operations, have a clearer distinction between the two cases. Here we: * Split token extraction and fetching documentation * Introduce a `DocResult` struct which stores additional information about the input token * Introduce basic telemetry for the *hover* operation This will expanded in a subsequent diff to include more information about the actual documentation. Reviewed By: alanz Differential Revision: D82948615 fbshipit-source-id: c0dbc2bb99519ef5522288ed5bc910f34105bc54 --- crates/elp/src/handlers.rs | 34 +++++++++++++++++++++++++---- crates/ide/src/handlers/get_docs.rs | 24 +++++++------------- crates/ide/src/lib.rs | 32 ++++++++++++++++++++++----- crates/ide/src/signature_help.rs | 5 +++-- 4 files changed, 68 insertions(+), 27 deletions(-) diff --git a/crates/elp/src/handlers.rs b/crates/elp/src/handlers.rs index cd8f110850..c853888d69 100644 --- a/crates/elp/src/handlers.rs +++ b/crates/elp/src/handlers.rs @@ -17,6 +17,7 @@ use std::time::SystemTime; use anyhow::Result; use anyhow::bail; use elp_ide::Cancellable; +use elp_ide::DocResult; use elp_ide::HighlightedRange; use elp_ide::NavigationTarget; use elp_ide::RangeInfo; @@ -364,6 +365,24 @@ fn goto_definition_telemetry(snap: &Snapshot, targets: &[NavigationTarget], star telemetry::send_with_duration("goto_definition".to_string(), data, duration, start); } +fn send_hover_telemetry(doc_result: &DocResult) { + #[derive(serde::Serialize)] + struct Data { + docs_found: bool, + text: String, + kind: String, + } + let detail = Data { + docs_found: doc_result.doc.is_some(), + text: doc_result.token_text.clone(), + kind: format!("{:?}", doc_result.token_kind), + }; + let data = serde_json::to_value(detail).unwrap_or_else(|err| { + serde_json::Value::String(format!("JSON serialization failed: {err}")) + }); + telemetry::send("hover".to_string(), data); +} + pub(crate) fn handle_goto_type_definition( snap: Snapshot, params: lsp_types::GotoDefinitionParams, @@ -456,8 +475,10 @@ pub(crate) fn handle_completion_resolve( position.offset = snap .analysis .clamp_offset(position.file_id, position.offset)?; - if let Ok(Some(res)) = snap.analysis.get_docs_at_position(position) { - let docs = res.0.markdown_text().to_string(); + if let Ok(Some(doc_result)) = snap.analysis.get_docs_at_position(position) + && let Some(doc) = doc_result.doc + { + let docs = doc.markdown_text().to_string(); let documentation = lsp_types::Documentation::MarkupContent(lsp_types::MarkupContent { kind: lsp_types::MarkupKind::Markdown, @@ -582,8 +603,13 @@ pub(crate) fn handle_hover(snap: Snapshot, params: HoverParams) -> Result